From 58fdc65609490c0dec21ed30645a4642cb1e4042 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 1 Oct 2026 18:22:37 -0700 Subject: [PATCH] test: add shared conformance adapters and reports --- .github/workflows/conformance.yml | 69 +++++ README.md | 3 + conformance/README.md | 81 +++++ conformance/__init__.py | 1 + conformance/adapter.py | 461 +++++++++++++++++++++++++++++ conformance/inflow-specs.lock.json | 4 + conformance/runtime-cases.mjs | 127 ++++++++ pyproject.toml | 2 +- scripts/conformance.mjs | 123 ++++++++ scripts/conformance.test.mjs | 107 +++++++ tests/test_conformance.py | 103 +++++++ 11 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/conformance.yml create mode 100644 conformance/README.md create mode 100644 conformance/__init__.py create mode 100644 conformance/adapter.py create mode 100644 conformance/inflow-specs.lock.json create mode 100644 conformance/runtime-cases.mjs create mode 100644 scripts/conformance.mjs create mode 100644 scripts/conformance.test.mjs create mode 100644 tests/test_conformance.py diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..12f1ff8 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,69 @@ +name: shared conformance + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + conformance: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + defaults: + run: + working-directory: sdk + steps: + - uses: actions/checkout@v7 + with: + path: sdk + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + - uses: astral-sh/setup-uv@v10.2.0 + with: + version: "0.11.8" + - uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Read contract pin + id: pin + run: | + node --input-type=module -e ' + import { readFileSync, appendFileSync } from "node:fs"; + const { revision } = JSON.parse(readFileSync("conformance/inflow-specs.lock.json", "utf8")); + if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error("Invalid contract revision"); + appendFileSync(process.env.GITHUB_OUTPUT, `revision=${revision}\n`); + ' + - uses: actions/checkout@v7 + with: + repository: inflowpayai/inflow-specs + ref: ${{ steps.pin.outputs.revision }} + path: contract + persist-credentials: false + - uses: pnpm/action-setup@v6 + with: + package_json_file: contract/package.json + - run: pnpm install --frozen-lockfile + working-directory: contract + - run: uv sync --all-extras --all-groups --locked --python "${{ matrix.python }}" + - run: node --test scripts/conformance.test.mjs + - name: Run shared suites + run: | + mkdir -p "$RUNNER_TEMP/inflow-python-reports" + node scripts/conformance.mjs --contract-root ../contract --output-dir "$RUNNER_TEMP/inflow-python-reports" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: inflow-python-conformance-${{ matrix.python }} + path: ${{ runner.temp }}/inflow-python-reports/*.json + if-no-files-found: warn + retention-days: 14 diff --git a/README.md b/README.md index 76e231c..72c7217 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ outside the checkout to verify imports and the `py.typed` marker. The consumer check exercises both the base installation and the combined optional dependencies without issuing payments or contacting InFlow. +[Shared conformance checks](https://github.com/inflowpayai/inflow-python/blob/main/conformance/README.md) exercise the public SDK against +pinned InFlow contract fixtures and produce reports for Python 3.11–3.14. + Protocol and framework dependencies are optional. The `mpp` and `x402` extras select payment libraries; `evm` and `svm` select x402 external-wallet dependencies; `mcp` selects MCP dependencies for both protocols; `fastapi` selects the optional diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..67de244 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,81 @@ +# Shared conformance checks + +These development checks run the public Python SDK against the fixtures and runner +in [inflow-specs](https://github.com/inflowpayai/inflow-specs). They do not ship in +the Python distribution or add production dependencies. + +## Run locally + +Use Node 24 and the pnpm version specified in the contract repository. Check out +`inflow-specs` at the full commit in `inflow-specs.lock.json`, in a separate local +directory with no uncommitted changes. Install its runner dependencies with +`pnpm install --frozen-lockfile`. + +From this repository: + +```sh +make sync +make verify +node --test scripts/conformance.test.mjs +reports=$(mktemp -d) +node scripts/conformance.mjs --contract-root ../inflow-specs --output-dir "$reports" +``` + +The script uses `.venv/bin/python` (`.venv/Scripts/python.exe` on Windows). +Set `CONFORMANCE_PYTHON` to select a different installed Python environment. That +environment must contain this SDK and all optional dependencies from the lockfile. + +The output directory must exist. Existing report files are never overwritten. +The reports record the SDK and contract commits, dirty-state flags, installed +package versions, case results, and explicit unsupported features. A dirty-tree +run helps during development; retain clean-checkout reports when verifying a release. + +The `shared conformance` workflow runs on Python 3.11–3.14 and uploads one report +artifact per Python version. A failed required case fails the job. + +## What the adapters exercise + +| Suite | Python entry points | Checks | +| ------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime | MPP `BuyerMethod` and `Seller.create`; x402 `Buyer.create` and `Seller.create` | Environment destinations, authentication errors, account-specific errors, redirects, request identifiers, sensitive-header removal, and operation-specific retry behavior. | +| MPP | Public codecs, `BuyerMethod.create_credential`, Seller preparation/validation, and pympp's `broadcast_credential` and `pay` | Wire data, approvals, cancellation, Buyer subscriptions, validation before broadcast, idempotency, and route binding. | +| x402 | Public identifier helpers, `Seller.offers`/`route`, `Buyer.prepare`, and `Facilitator.verify`/`settle` | Offer construction, sponsorship declarations, approval lifecycle, cancellation, concurrent waits, payment identifiers, and verification/settlement. | + +The runner owns the loopback HTTP servers, expected request sequences and results. +The Python process receives inputs, not expected outcomes or response scripts. +Polling, retry, cancellation, validation and broadcast remain in the SDK and its +upstream libraries. Unknown adapter exceptions fail the case; they are not payment +successes or runtime skips. + +Runtime cases use public product operations rather than exposing the SDK's private +HTTP client. Both Seller factories require an API key, so their runtime cases use +API keys; Buyer cases also cover Bearer and missing authentication. MPP Seller +configuration reads retry transient failures. The other three construction/payment +operations do not. These cases test those actual operation policies, not a generic +rule that every GET is retryable. Approval success and cancellation are exercised +by the payment suites rather than a separate raw approval client. + +## Test setup and limits + +Environment checks capture requests in a transport without sending them to public +hosts. Payment requests go only to the runner's `127.0.0.1` server. Use synthetic +credentials; these checks do not make live payments or prove server authorization. + +x402 offer cases supply configuration through an HTTPX test transport. Python +Seller construction also loads supported capabilities; cases that do not supply +capabilities receive an empty list. Route cases use their supplied capabilities. +For facilitator verification/settlement and runtime Seller configuration-error +checks, the transport supplies an empty capabilities response for the construction +request only. Verification, settlement and configuration-error requests still use +real HTTP to the runner. This setup does not claim to test capability discovery; +the native SDK tests cover that construction behavior. + +Ten MPP Seller-subscription cases are explicitly skipped because pympp's Seller +routes do not expose the necessary subscription terms. See +[pympp issue #269](https://github.com/tempoxyz/pympp/issues/269). +MPP Buyer-subscription cases remain required and execute normally. + +Comparisons use the pinned runner's documented cross-language equivalences, including +the three descriptive EIP-2612 strings. The adapter does not replace schema constraints +or payment values to match expected output. Native unit, framework, consumer and +coverage checks remain required alongside these shared reports. diff --git a/conformance/__init__.py b/conformance/__init__.py new file mode 100644 index 0000000..a00db7e --- /dev/null +++ b/conformance/__init__.py @@ -0,0 +1 @@ +"""Development-only shared conformance adapters.""" diff --git a/conformance/adapter.py b/conformance/adapter.py new file mode 100644 index 0000000..7b84d3d --- /dev/null +++ b/conformance/adapter.py @@ -0,0 +1,461 @@ +import asyncio +import json +import sys +from copy import deepcopy +from typing import Any +from urllib.parse import urlsplit + +import httpx +from fastapi import FastAPI, Request +from mpp import Challenge, Credential, Receipt +from mpp.server.decorator import pay +from mpp.server.intent import broadcast_credential +from starlette.responses import JSONResponse +from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo + +from inflowpay import ClientOptions, InflowApiError, mpp, x402 +from inflowpay.mpp.buyer import ( + BuyerMethod, + MppMalformedCredentialError, + MppPaymentExpiredError, + MppPaymentFailedError, + MppPaymentTimeoutError, +) +from inflowpay.mpp.seller import MppCredentialProblemError, MppSellerConfigurationError +from inflowpay.mpp.seller import Seller as MppSeller +from inflowpay.x402.buyer import Buyer, X402PaymentError +from inflowpay.x402.facilitator import Facilitator +from inflowpay.x402.seller import Seller + +# JSON messages are validated by the shared runner. Dynamic mappings stay at this test boundary. +Data = dict[str, Any] + + +def options(data: Data, transport: httpx.AsyncBaseTransport | None = None) -> ClientOptions: + base = data["base_url"] + parsed = urlsplit(base) + if ( + parsed.scheme != "http" + or parsed.hostname != "127.0.0.1" + or parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + ): + raise RuntimeError("Adapter requires a loopback platform URL") + return ClientOptions(base_url=base, api_key=data.get("api_key"), transport=transport) + + +def classify(error: BaseException, operation: str, data: Data) -> Data: + details: Data = {} + if isinstance(error, InflowApiError): + if operation.startswith("x402."): + return { + "code": "api-error", + "message": "InFlow API request failed.", + "http_status": error.http_status, + "details": {"body": error.body}, + } + return {"code": error.code, "message": str(error), "http_status": error.http_status} + if isinstance(error, (MppPaymentExpiredError, MppPaymentTimeoutError)): + code = "payment-expired" if isinstance(error, MppPaymentExpiredError) else "payment-timeout" + if error.transaction_id is not None: + details["transaction_id"] = error.transaction_id + elif isinstance(error, MppPaymentFailedError): + code = "payment-failed" + if error.problem is not None: + details["problem"] = error.problem + elif isinstance(error, MppCredentialProblemError): + code = "payment-failed" + if data.get("include_problem", True): + details["problem"] = error.problem + elif isinstance(error, MppMalformedCredentialError): + code = "invalid-credential" + elif isinstance(error, mpp.MppCodecError): + code = ( + "invalid-credential" if operation == "mpp.core.decode-credential" else "invalid-input" + ) + elif isinstance(error, MppSellerConfigurationError): + code = "unsupported-capability" + elif isinstance(error, asyncio.CancelledError) and operation == "mpp.buyer.cancel": + code = "payment-cancelled" + elif isinstance(error, X402PaymentError): + code = error.code + if error.status is not None: + details["status"] = error.status + elif ( + type(error) is ValueError + and operation in ("x402.seller.offers", "x402.seller.route") + and str(error) + in ( + "Price must be '$1.00', '1.00 USDC', or a plain amount with currency", + "A currency is required for a plain amount", + "Price cannot be represented in the asset's decimal precision", + ) + ) or ( + type(error) is ValueError + and operation == "x402.buyer.sign" + and str(error) == "Invalid payment identifier" + ): + code = "invalid-input" + else: + raise error + messages = { + "invalid-input": "Invalid input.", + "invalid-credential": "Invalid credential.", + "payment-failed": "Payment failed.", + "payment-expired": "Payment expired.", + "payment-timeout": "Payment timed out.", + "payment-cancelled": "Payment cancelled.", + "unsupported-capability": "Unsupported payment capability.", + } + if code not in messages: + raise error + return {"code": code, "message": messages[code], **({"details": details} if details else {})} + + +async def mpp_execute(operation: str, data: Data) -> object: + match operation: + case "mpp.core.encode": + return mpp.encode(data["value"]) + case "mpp.core.decode": + return mpp.decode(data["value"]) + case "mpp.core.decode-credential": + return mpp.decode_credential(data["value"]) + case "mpp.core.decode-receipt": + return mpp.decode_receipt(data["value"]) + case "mpp.core.parse-challenges": + return mpp.parse_challenge_headers(data["headers"]) + if operation in ("mpp.buyer.fulfil", "mpp.buyer.cancel"): + challenge = mpp.to_pympp_challenge(data["challenge"]) + payment: asyncio.Task[Credential] + + class Observe(httpx.AsyncHTTPTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + response = await super().handle_async_request(request) + await response.aread() + if operation == "mpp.buyer.cancel" and request.url.path.endswith( + "/transactions/mpp" + ): + asyncio.get_running_loop().call_soon(payment.cancel) + return response + + async with BuyerMethod( + options(data, Observe()), + method=challenge.method, + intent=challenge.intent, + poll_interval=0, + pending_timeout=data.get("timeout_ms", 5000) / 1000, + instrument_id=data["context"].get("instrumentId"), + subscription_id=data["context"].get("subscriptionId"), + ) as buyer: + payment = asyncio.create_task(buyer.create_credential(challenge)) + return mpp.decode_credential((await payment).to_authorization()[8:]) + allowed = ( + "mpp.seller.prepare", + "mpp.seller.validate", + "mpp.seller.verify", + "mpp.seller.route-binding", + ) + if operation not in allowed: + raise RuntimeError("Unknown MPP operation") + wire = data.get("credential") + method = wire["challenge"]["method"] if wire else data["method"] + async with await MppSeller.create(options(data), method=method) as seller: + if operation == "mpp.seller.prepare": + return seller.charge_request(data["request"]) + if operation == "mpp.seller.route-binding": + return await route_binding(seller, data) + credential = Credential.from_authorization("Payment " + mpp.encode(wire)) + request = mpp.decode(credential.challenge.request) + if not isinstance(request, dict): + raise RuntimeError("Expected an object request") + if operation == "mpp.seller.verify": + receipt = await broadcast_credential( + intent=seller, credential=credential, request=request + ) + return mpp.decode_receipt(receipt.to_payment_receipt()) + value = await seller.validate(credential, request) + observed = mpp.decode_credential(value.credential.to_authorization()[8:]) + return { + "success": True, + "challenge": observed["challenge"], + "credential": observed, + "details": value.details, + "method": method, + "intent": value.intent, + "request": value.request, + "source": observed.get("source", ""), + } + + +async def route_binding(seller: MppSeller, data: Data) -> object: + app = FastAPI() + terms = seller.charge_request(data["request"]) + + @app.get("/test") + @pay( + intent=seller, + method=seller.method, + request=lambda _: terms, + realm="seller.example", + secret_key="test-only-binding-secret-at-least-32-bytes", + ) + async def handler(request: Request, credential: Credential, receipt: Receipt) -> JSONResponse: + return JSONResponse({"ok": True}) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://seller.example" + ) as client: + initial = await client.get("/test") + challenge = Challenge.from_www_authenticate(initial.headers["www-authenticate"]) + credential = Credential( + challenge=challenge.to_echo(), payload=data["credential_payload"], source=data["source"] + ) + terms = seller.charge_request(data["replacement_request"]) + response = await client.get( + "/test", headers={"Authorization": credential.to_authorization()} + ) + return {"status": response.status_code} + + +async def x402_execute(operation: str, data: Data) -> object: + match operation: + case "x402.core.identifier-valid": + return x402.validate_payment_id(data["value"]) + case "x402.core.identifier-declaration": + return x402.declare_payment_identifier() + case "x402.core.identifier-entry": + return x402.payment_identifier_entry(data["declaration"], data["payment_id"]) + if operation in ("x402.seller.offers", "x402.seller.route"): + + def configuration(request: httpx.Request) -> httpx.Response: + if request.method != "GET" or request.url.path not in ( + "/v1/x402/config", + "/v1/x402/supported", + ): + raise RuntimeError("Unexpected configuration request") + return httpx.Response( + 200, + json=data["config"] + if request.url.path.endswith("config") + else data.get("supported", {"kinds": []}), + ) + + async with await Seller.create( + ClientOptions( + api_key="test-only-seller-key", transport=httpx.MockTransport(configuration) + ) + ) as seller: + arguments = data["options"] + kwargs = { + key: arguments[key] + for key in ("currency", "schemes", "networks") + if key in arguments + } + if "maxTimeoutSeconds" in arguments: + kwargs["max_timeout_seconds"] = arguments["maxTimeoutSeconds"] + + def offer(value: Any) -> Data: + return { + "scheme": value.scheme, + "network": value.network, + "payTo": value.pay_to, + "price": value.price.model_dump(exclude_none=True), + "maxTimeoutSeconds": value.max_timeout_seconds, + "extra": value.extra, + } + + if operation == "x402.seller.offers": + return [offer(value) for value in await seller.offers(arguments["price"], **kwargs)] + route = await seller.route( + arguments["price"], + permit2=arguments.get("assetTransferMethod") == "permit2", + **kwargs, + ) + if not isinstance(route.accepts, list): + raise RuntimeError("Expected static offers") + return { + "accepts": [offer(value) for value in route.accepts], + **({"extensions": route.extensions} if route.extensions is not None else {}), + } + if operation in ("x402.buyer.sign", "x402.buyer.cancel", "x402.buyer.concurrent-await"): + async with await Buyer.create( + options(data), + poll_interval=data.get("poll_interval_ms", 1) / 1000, + pending_timeout=data.get("timeout_ms", 2000) / 1000, + ) as buyer: + payment = await buyer.prepare( + PaymentRequirements.model_validate(data["requirement"]), + ResourceInfo.model_validate(data["context"]["resource"]), + payment_id=data["payment_id"], + ) + if operation == "x402.buyer.cancel": + await payment.cancel() + if operation == "x402.buyer.concurrent-await": + result, other = await asyncio.gather( + payment.await_payload(), payment.await_payload() + ) + if result != other: + raise RuntimeError("Concurrent results differ") + else: + result = await payment.await_payload() + return { + "encodedPayload": result.encoded_payload, + "paymentPayload": result.payment_payload.model_dump( + by_alias=True, exclude_none=True + ), + "transactionId": result.transaction_id, + } + if operation not in ("x402.seller.verify", "x402.seller.settle", "x402.seller.verify-settle"): + raise RuntimeError("Unknown x402 operation") + + class Setup(httpx.AsyncHTTPTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + # Python's synchronous upstream interface requires capabilities at construction. + # These operations do not consume them; all verify/settle HTTP stays runner-owned. + if request.method == "GET" and request.url.path == "/v1/x402/supported": + return httpx.Response(200, json={"kinds": []}) + return await super().handle_async_request(request) + + async with await Facilitator.create( + options(data, Setup()), anonymous="api_key" not in data + ) as facilitator: + payload = PaymentPayload.model_validate(data["payment_payload"]) + requirement = PaymentRequirements.model_validate(data["payment_requirements"]) + if operation == "x402.seller.settle": + return (await facilitator.settle(payload, requirement)).model_dump(exclude_none=True) + verified = await facilitator.verify(payload, requirement) + if operation == "x402.seller.verify": + return verified.model_dump(exclude_none=True) + observations = {"verification": verified.model_dump(exclude_none=True)} + if verified.is_valid: + observations["settlement"] = ( + await facilitator.settle(payload, requirement) + ).model_dump(exclude_none=True) + return observations + + +async def runtime_call(product: str, config: ClientOptions) -> None: + if product == "mpp-buyer": + async with BuyerMethod(config) as buyer: + await buyer.create_credential( + mpp.to_pympp_challenge( + { + "id": "test", + "realm": "seller.example", + "method": "inflow", + "intent": "charge", + "request": mpp.encode({"amount": "1", "currency": "USD"}), + } + ) + ) + elif product == "mpp-seller": + async with await MppSeller.create(config): + pass + elif product == "x402-buyer": + async with await Buyer.create(config): + pass + elif product == "x402-seller": + async with await Seller.create(config): + pass + else: + raise RuntimeError("Unknown runtime product") + + +async def runtime_execute(operation: str, data: Data) -> Data: + if operation not in ("runtime.environment", "runtime.request"): + raise RuntimeError("Unknown runtime operation") + destinations = [] + token_calls = 0 + + async def token() -> str: + nonlocal token_calls + value: str = data["tokens"][token_calls] + token_calls += 1 + return value + + def capture(request: httpx.Request) -> httpx.Response: + destinations.append(f"{request.method} {request.url}") + return httpx.Response(403) + + class RuntimeTransport(httpx.AsyncHTTPTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + # Seller construction loads capabilities alongside config. The runtime cases + # exercise config errors; payment capability behavior belongs to the x402 suite. + if request.method == "GET" and request.url.path.endswith("/v1/x402/supported"): + return httpx.Response(200, json={"kinds": []}) + if operation == "runtime.environment": + return capture(request) + return await super().handle_async_request(request) + + if operation == "runtime.request": + options(data) # Validate the runner's destination before allowing real HTTP. + config = ClientOptions( + environment=data.get("environment", "production"), + base_url=data.get("base_url"), + api_key=data.get("api_key"), + access_token=token if "tokens" in data else None, + transport=RuntimeTransport(), + ) + try: + await runtime_call(data["product"], config) + except InflowApiError as error: + if operation == "runtime.environment": + return {"destinations": destinations} + return { + "code": error.code, + "message": str(error), + "http_status": error.http_status, + "endpoint": error.endpoint, + "request_id": error.request_id or "", + "token_calls": token_calls, + "sensitive_headers": [ + name + for name in error.headers + if name.lower() in ("authorization", "cookie", "set-cookie", "x-api-key") + ], + } + raise RuntimeError("Expected the scripted runtime request to fail") + + +async def respond(request: Data) -> Data: + envelope = { + "adapter_version": "1", + "sequence": request["sequence"], + "case_id": request["case_id"], + } + try: + if request["adapter_version"] != "1": + raise RuntimeError("Unsupported adapter version") + data, operation = request["input"], request["operation"] + before = deepcopy(data) + try: + if operation.startswith("mpp."): + result = await mpp_execute(operation, data) + elif operation.startswith("x402."): + result = await x402_execute(operation, data) + elif operation.startswith("runtime."): + result = await runtime_execute(operation, data) + else: + raise RuntimeError("Unknown operation") + observation = {"result": result} + except (Exception, asyncio.CancelledError) as error: + observation = {"error": classify(error, operation, data)} + if data != before: + raise RuntimeError("Caller input was mutated") + return {**envelope, **observation} + except Exception as error: + return {**envelope, "error": {"code": "ADAPTER_ERROR", "message": str(error)}} + + +async def main() -> None: + for line in sys.stdin: + request = json.loads(line) + response = await respond(request) + print(json.dumps(response, allow_nan=False), flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/conformance/inflow-specs.lock.json b/conformance/inflow-specs.lock.json new file mode 100644 index 0000000..e425ce7 --- /dev/null +++ b/conformance/inflow-specs.lock.json @@ -0,0 +1,4 @@ +{ + "repository": "inflowpayai/inflow-specs", + "revision": "d79cc3ab3b3acde196e41d15783ed3d119f19379" +} diff --git a/conformance/runtime-cases.mjs b/conformance/runtime-cases.mjs new file mode 100644 index 0000000..a125b3a --- /dev/null +++ b/conformance/runtime-cases.mjs @@ -0,0 +1,127 @@ +const clients = { + "mpp-buyer": ["POST", "/v1/transactions/mpp"], + "mpp-seller": ["GET", "/v1/mpp/config"], + "x402-buyer": ["GET", "/v1/transactions/x402-supported"], + "x402-seller": ["GET", "/v1/x402/config"], +}; + +export function runtimeCases(scenarios) { + const cases = []; + for (const [product, [method, path]] of Object.entries(clients)) { + for (const [name, options, base] of [ + ["default", {}, "https://api.inflowpay.ai"], + ["production", { environment: "production" }, "https://api.inflowpay.ai"], + ["sandbox", { environment: "sandbox" }, "https://sandbox.inflowpay.ai"], + [ + "override", + { environment: "sandbox", base_url: "http://127.0.0.1:1234/prefix/" }, + "http://127.0.0.1:1234/prefix", + ], + ]) + cases.push({ + id: `${product}.environment.${name}`, + suite: "runtime", + operation: "runtime.environment", + input: { product, api_key: "test-only-key", ...options }, + expect: { result: { destinations: [`${method} ${base}${path}`] } }, + }); + const request = (headers) => ({ + method, + path, + headers, + ...(method === "POST" + ? { + json: { + challenge: { + id: "test", + realm: "seller.example", + method: "inflow", + intent: "charge", + request: Buffer.from( + JSON.stringify({ amount: "1", currency: "USD" }), + ).toString("base64url"), + }, + options: {}, + }, + } + : {}), + }); + const add = (id, input, exchanges, response) => { + const entry = response.json?.errors?.[0]; + cases.push({ + id: `${product}.${id}`, + suite: "runtime", + operation: "runtime.request", + input: { product, ...input }, + platform: { exchanges }, + expect: { + result: { + code: entry?.code ?? "UNEXPECTED_ERROR", + message: entry?.message ?? "request failed", + http_status: response.status, + endpoint: path, + token_calls: input.tokens?.length ?? 0, + request_id: response.headers?.["x-request-id"] ?? "", + sensitive_headers: [], + }, + }, + }); + }; + for (const [id, scenario] of Object.entries(scenarios)) { + if (!id.startsWith("auth.")) continue; + const { request: original, response } = scenario.exchanges[0]; + if (response.status < 400) continue; + if (product.endsWith("seller") && !original.headers["x-api-key"]) + continue; + if (id.startsWith("auth.seller-required") && !product.endsWith("seller")) + continue; + const headers = original.headers; + const input = headers.authorization + ? { tokens: [headers.authorization.slice(7)] } + : headers["x-api-key"] + ? { api_key: headers["x-api-key"] } + : {}; + add(id, input, [{ request: request(headers), response }], response); + } + for (const status of [ + 301, 302, 303, 307, 308, 400, 401, 403, 404, 409, 412, 500, + ]) { + const response = { + status, + headers: { + location: "/must-not-follow", + "x-request-id": "test-request", + "set-cookie": "test-only-secret", + }, + }; + add( + `http.${status}`, + { api_key: "test-only-key" }, + [{ request: request({ "x-api-key": "test-only-key" }), response }], + response, + ); + } + // Retry permission belongs to each public operation, not to GET in general. + const retry = product === "mpp-seller"; + const transient = { status: 503 }; + const unauthorized = { status: 401 }; + const exchanges = [ + { + request: request({ "x-api-key": "test-only-key" }), + response: transient, + }, + ]; + if (retry) + exchanges.push({ + request: request({ "x-api-key": "test-only-key" }), + response: unauthorized, + }); + add( + "retry-policy", + { api_key: "test-only-key" }, + exchanges, + retry ? unauthorized : transient, + ); + } + return { cases }; +} diff --git a/pyproject.toml b/pyproject.toml index 30758fe..9cbc4b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ packages = ["src/inflowpay"] [tool.mypy] python_version = "3.11" strict = true -files = ["src", "tests", "scripts", "examples"] +files = ["src", "tests", "scripts", "examples", "conformance"] [tool.pytest.ini_options] addopts = [ diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs new file mode 100644 index 0000000..8d90eac --- /dev/null +++ b/scripts/conformance.mjs @@ -0,0 +1,123 @@ +import { execFileSync } from "node:child_process"; +import { readFile, open } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { runtimeCases } from "../conformance/runtime-cases.mjs"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const command = (program, args, cwd = root) => + execFileSync(program, args, { + cwd, + encoding: "utf8", + timeout: 180000, + }).trim(); +export function checkContract(path, revision) { + if (!/^[0-9a-f]{40}$/.test(revision)) + throw new Error("Expected a full contract commit"); + if ( + command("git", ["rev-parse", "HEAD"], path) !== revision || + command("git", ["status", "--porcelain"], path) + ) + throw new Error(`Use a clean contract checkout at ${revision}`); +} +async function main() { + const { values } = parseArgs({ + options: { + "contract-root": { type: "string" }, + "output-dir": { type: "string" }, + }, + }); + if (!values["contract-root"] || !values["output-dir"]) + throw new Error("Use --contract-root PATH --output-dir EXISTING_DIRECTORY"); + const contractRoot = resolve(values["contract-root"]); + const pin = JSON.parse( + await readFile( + new URL("../conformance/inflow-specs.lock.json", import.meta.url), + "utf8", + ), + ); + checkContract(contractRoot, pin.revision); + const python = + process.env.CONFORMANCE_PYTHON || + join( + root, + ".venv", + process.platform === "win32" ? "Scripts/python.exe" : "bin/python", + ); + const implementation = JSON.parse( + command(python, [ + "-c", + `import json,platform; from importlib.metadata import distributions,version; print(json.dumps(dict(name="inflow-python",runtime="Python "+platform.python_version(),packages={"inflowpay":version("inflowpay")},dependencies={d.metadata["Name"]:d.version for d in distributions() if d.metadata["Name"]!="inflowpay"})))`, + ]), + ); + const { run } = await import( + pathToFileURL(join(contractRoot, "runner/run.mjs")) + ); + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + try { + for (const suite of ["runtime", "mpp", "x402"]) { + if (controller.signal.aborted) throw new Error("Conformance interrupted"); + const fixtures = await import( + pathToFileURL(join(contractRoot, `fixtures/${suite}.mjs`)) + ); + const output = await open( + join(resolve(values["output-dir"]), `${suite}.json`), + "wx", + 0o600, + ); + try { + const report = await run({ + index: + suite === "runtime" + ? runtimeCases(fixtures.runtimeScenarios) + : fixtures[`${suite}Cases`], + capabilities: { + suites: + suite === "runtime" + ? ["runtime"] + : [`${suite}-core`, `${suite}-buyer`, `${suite}-seller`], + supported_features: [], + unsupported_features: + suite === "mpp" + ? [ + { + id: "mpp-seller-subscriptions", + reason: + "pympp Seller routes do not expose subscription terms; see tempoxyz/pympp#269.", + }, + ] + : [], + }, + implementation, + command: [python, join(root, "conformance/adapter.py")], + contractRoot, + sdkRoot: root, + signal: controller.signal, + }); + await output.writeFile(JSON.stringify(report, null, 2) + "\n"); + console.log( + `${suite}: ${report.results.filter((r) => r.status === "passed").length}/${report.results.length} passed`, + ); + if (!report.passed) { + process.exitCode = 1; + console.error( + report.runner_error ?? + report.results.filter( + (r) => r.status !== "passed" && r.status !== "skipped", + ), + ); + } + } finally { + await output.close(); + } + } + } finally { + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); + } +} +if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/conformance.test.mjs b/scripts/conformance.test.mjs new file mode 100644 index 0000000..e98e4cd --- /dev/null +++ b/scripts/conformance.test.mjs @@ -0,0 +1,107 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { runtimeCases } from "../conformance/runtime-cases.mjs"; +import { checkContract } from "./conformance.mjs"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +test("runtime cases preserve fixtures and use public operations", () => { + const fixtures = { + "auth.invalid-key": { + exchanges: [ + { + request: { headers: { "x-api-key": "test-only-key" } }, + response: { status: 401 }, + }, + ], + }, + "auth.expired-bearer": { + exchanges: [ + { + request: { headers: { authorization: "Bearer test-only-token" } }, + response: { status: 401 }, + }, + ], + }, + "auth.missing": { + exchanges: [{ request: { headers: {} }, response: { status: 401 } }], + }, + "auth.seller-required-developer-key": { + exchanges: [ + { + request: { headers: { "x-api-key": "test-only-key" } }, + response: { + status: 403, + json: { + errors: [ + { code: "SELLER_ACCOUNT_REQUIRED", message: "Seller required" }, + ], + }, + }, + }, + ], + }, + "approval.cancel": { exchanges: [] }, + "auth.success": { + exchanges: [{ request: { headers: {} }, response: { status: 200 } }], + }, + }; + const before = structuredClone(fixtures); + const { cases } = runtimeCases(fixtures); + assert.deepEqual(fixtures, before); + assert.equal(new Set(cases.map((item) => item.id)).size, cases.length); + assert.equal( + cases.filter((item) => item.operation === "runtime.environment").length, + 16, + ); + for (const item of cases) { + if (item.id.includes("seller-required")) + assert.ok(item.input.product.endsWith("seller")); + if (item.input.product.endsWith("seller")) assert.ok(item.input.api_key); + if (item.id.endsWith("retry-policy")) + assert.equal( + item.platform.exchanges.length, + item.input.product === "mpp-seller" ? 2 : 1, + ); + assert.equal( + item.input.tokens?.length ?? 0, + item.expect.result.token_calls ?? 0, + ); + } +}); + +test("invalid, mismatched and dirty contract revisions are rejected", () => { + assert.throws(() => checkContract(root, "main"), /full contract commit/); + assert.throws( + () => checkContract(root, "0".repeat(40)), + /clean contract checkout/, + ); + const revision = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + if ( + execFileSync("git", ["status", "--porcelain"], { + cwd: root, + encoding: "utf8", + }).trim() + ) + assert.throws( + () => checkContract(root, revision), + /clean contract checkout/, + ); + else checkContract(root, revision); +}); + +test("missing output configuration fails before launching an adapter", () => { + const result = spawnSync(process.execPath, ["scripts/conformance.mjs"], { + cwd: root, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /--contract-root PATH --output-dir EXISTING_DIRECTORY/, + ); +}); diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..a471d77 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,103 @@ +import asyncio +import json +import subprocess +import sys +from copy import deepcopy + +import pytest +from conformance.adapter import classify, options, respond, runtime_execute + + +@pytest.mark.parametrize( + "url", + [ + "https://api.inflowpay.ai", + "http://localhost:1234", + "http://127.0.0.1:1234/path", + "http://key@127.0.0.1:1234", + "http://127.0.0.1:1234?query=1", + "http://127.0.0.1:1234#fragment", + ], +) +def test_network_destination_is_loopback_only(url: str) -> None: + with pytest.raises(RuntimeError, match="loopback"): + options({"base_url": url}) + + +@pytest.mark.parametrize( + "product,path,method", + [ + ("mpp-buyer", "/v1/transactions/mpp", "POST"), + ("mpp-seller", "/v1/mpp/config", "GET"), + ("x402-buyer", "/v1/transactions/x402-supported", "GET"), + ("x402-seller", "/v1/x402/config", "GET"), + ], +) +async def test_environment_uses_public_clients_without_outbound_http( + product: str, + path: str, + method: str, +) -> None: + for environment, base in [ + ("production", "https://api.inflowpay.ai"), + ("sandbox", "https://sandbox.inflowpay.ai"), + ]: + result = await runtime_execute( + "runtime.environment", + { + "product": product, + "environment": environment, + "api_key": "test-only-key", + }, + ) + assert result == {"destinations": [f"{method} {base}{path}"]} + + +async def test_adapter_preserves_caller_input_and_reports_unknown_operations() -> None: + request = { + "adapter_version": "1", + "sequence": 1, + "case_id": "test", + "operation": "mpp.core.encode", + "input": {"value": {"amount": "1"}}, + } + before = deepcopy(request) + response = await respond(request) + assert response["result"] == "eyJhbW91bnQiOiIxIn0" + assert request == before + request["operation"] = "unknown" + assert (await respond(request))["error"]["code"] == "ADAPTER_ERROR" + request["adapter_version"] = "2" + assert (await respond(request))["error"]["message"] == "Unsupported adapter version" + + +def test_unknown_errors_are_not_payment_outcomes() -> None: + for operation in ("x402.buyer.sign", "x402.seller.offers", "x402.seller.route"): + for error in (RuntimeError("bug"), ValueError("bug"), asyncio.CancelledError()): + with pytest.raises(type(error)): + classify(error, operation, {}) + + +def test_json_lines_process() -> None: + request = { + "adapter_version": "1", + "sequence": 1, + "case_id": "test", + "operation": "x402.core.identifier-valid", + "input": {"value": "short"}, + } + result = subprocess.run( + [sys.executable, "-m", "conformance.adapter"], + input=json.dumps(request) + "\n", + text=True, + capture_output=True, + timeout=10, + check=True, + ) + assert json.loads(result.stdout) == { + "adapter_version": "1", + "sequence": 1, + "case_id": "test", + "result": False, + } + assert not result.stderr