From f9688faabdbd32432cac7cfe2347fb6547d5450d Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sun, 23 Aug 2026 13:48:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(conformance):=20wave=2015=20=E2=80=94=20fi?= =?UTF-8?q?nal=20VM=20sweep,=20every=20real=20API=20adapter=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-level suites for all 21 remaining real-API adapters: linkedin, dropbox, avalara, walletconnect, revenuecat, opensea, onfido, pinata, dune, tenderly, sendgrid, persona, jumio, oneinch, cloudkit, signin-with-apple, apple-apns, reddit, etherscan, thegraph, erc4337. 235 behavior markers (1079 VM behaviors total). VM tier 38 -> 59; boot tier 24 -> 3 (the three 0-route demos only). ~25 fidelity fixes, the standouts: - avalara: _to_float returned 0.0 for all strings — SDK decimal-string requests computed ZERO tax - pinata: CIDs now real CIDv0 multihashes (identical content dedupes with isDuplicate); file pins read the multipart upload; real default pageLimit 10 - cloudkit: every record delete 500'd (store handed a dict where it wants the id); JSON-number resultsLimit 500'd on len() - tenderly: _hex_pad coerced 0 -> 1 — every simulation shared one transaction hash and zero values rendered 0x1 - linkedin: post resolution returned the CALLER as author; float-in-URN ids (urn:li:share:1.0); OAuth code burned before client validation (the fifth instance — standardization now complete); forged comment actors accepted - dropbox: re-upload forked the path (stale reads forever); mode overwrite/update/autorename now honored per the real write-mode contract - revenuecat: dual-key model (public SDK keys gated off restricted writes); full v1 CustomerInfo envelope - walletconnect: symKey minted; relay protocol echoed; sendgrid: half-configured webhooks rejected; etherscan: NOTOK envelope; opensea: V1ErrorWrapper + bytes32 zone_hash; onfido: blank-field 422s - persona: one-shot terminal webhook consumed on the created->pending hop (polling clients never got inquiry.completed) 36 new sidecar bullets (reviewer-verified); duplicate revenuecat/ sendgrid bullets and a wrong etherscan missing bullet corrected; two engine-test response-body leaks fixed. --- CONFORMANCE.md | 426 +++++++- adapters/apple-apns-style/scripts/lib.star | 20 +- adapters/apple_apns_style_test.go | 289 ++++++ adapters/avalara-style/README.md | 12 +- adapters/avalara-style/adapter.yaml | 1 + adapters/avalara-style/scripts/lib.star | 37 +- .../avalara-style/scripts/transactions.star | 8 +- adapters/avalara_style_test.go | 552 +++++++++++ adapters/cloudkit-style/scripts/lib.star | 7 +- adapters/cloudkit-style/scripts/records.star | 3 +- adapters/cloudkit_style_test.go | 467 +++++++++ adapters/dropbox-style/README.md | 2 +- adapters/dropbox-style/scripts/files.star | 57 +- adapters/dropbox_style_test.go | 732 ++++++++++++++ adapters/dune-style/scripts/lib.star | 7 +- adapters/dune_style_test.go | 403 ++++++++ adapters/erc4337_style_test.go | 404 ++++++++ adapters/etherscan-style/README.md | 2 +- adapters/etherscan-style/scripts/lib.star | 8 +- adapters/etherscan_style_test.go | 259 +++++ adapters/jumio_style_test.go | 564 +++++++++++ adapters/linkedin-style/scripts/comments.star | 19 +- adapters/linkedin-style/scripts/oauth.star | 6 +- adapters/linkedin-style/scripts/posts.star | 6 +- adapters/linkedin_style_test.go | 936 ++++++++++++++++++ adapters/oneinch_style_test.go | 398 ++++++++ adapters/onfido-style/scripts/applicants.star | 10 +- adapters/onfido-style/scripts/documents.star | 7 +- adapters/onfido_style_test.go | 456 +++++++++ adapters/opensea-style/scripts/lib.star | 7 +- adapters/opensea_style_test.go | 512 ++++++++++ adapters/persona-style/scripts/inquiries.star | 7 +- adapters/persona_style_test.go | 629 ++++++++++++ adapters/pinata-style/README.md | 31 +- adapters/pinata-style/adapter.yaml | 3 + adapters/pinata-style/scripts/data.star | 16 +- adapters/pinata-style/scripts/lib.star | 56 +- adapters/pinata-style/scripts/pinning.star | 96 +- adapters/pinata_style_test.go | 334 +++++++ adapters/reddit_style_test.go | 308 ++++++ adapters/revenuecat-style/README.md | 21 +- adapters/revenuecat-style/adapter.yaml | 6 +- adapters/revenuecat-style/scripts/lib.star | 62 +- .../revenuecat-style/scripts/receipts.star | 4 +- .../revenuecat-style/scripts/subscribers.star | 6 +- .../revenuecat-style/scripts/webhooks.star | 4 +- adapters/revenuecat_style_test.go | 378 +++++++ adapters/sendgrid-style/scripts/webhooks.star | 13 + adapters/sendgrid_style_test.go | 505 ++++++++++ adapters/signin_with_apple_style_test.go | 411 ++++++++ adapters/tenderly-style/scripts/lib.star | 23 +- adapters/tenderly_style_test.go | 366 +++++++ adapters/thegraph_style_test.go | 390 ++++++++ adapters/walletconnect-style/adapter.yaml | 4 + adapters/walletconnect-style/scripts/lib.star | 3 + .../walletconnect-style/scripts/relay.star | 12 +- adapters/walletconnect_style_test.go | 525 ++++++++++ conformance/matrix.json | 409 +++++++- conformance/matrix.yaml | 42 +- internal/engine/sendgrid_style_test.go | 1 + .../engine/signin_with_apple_style_test.go | 1 + 61 files changed, 11011 insertions(+), 272 deletions(-) create mode 100644 adapters/apple_apns_style_test.go create mode 100644 adapters/avalara_style_test.go create mode 100644 adapters/cloudkit_style_test.go create mode 100644 adapters/dropbox_style_test.go create mode 100644 adapters/dune_style_test.go create mode 100644 adapters/erc4337_style_test.go create mode 100644 adapters/etherscan_style_test.go create mode 100644 adapters/jumio_style_test.go create mode 100644 adapters/linkedin_style_test.go create mode 100644 adapters/oneinch_style_test.go create mode 100644 adapters/onfido_style_test.go create mode 100644 adapters/opensea_style_test.go create mode 100644 adapters/persona_style_test.go create mode 100644 adapters/pinata_style_test.go create mode 100644 adapters/reddit_style_test.go create mode 100644 adapters/revenuecat_style_test.go create mode 100644 adapters/sendgrid_style_test.go create mode 100644 adapters/signin_with_apple_style_test.go create mode 100644 adapters/tenderly_style_test.go create mode 100644 adapters/thegraph_style_test.go create mode 100644 adapters/walletconnect_style_test.go diff --git a/CONFORMANCE.md b/CONFORMANCE.md index de12322d..aa69f853 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -21,7 +21,7 @@ Verification tiers: the all-adapters-boot guard on every CI run; no SDK suite drives it yet. - Every adapter additionally documents its behavior in depth in its README. -**98 adapters** — 2 SDK+VM, 34 SDK-only, 38 VM-only, 24 boot-tier. +**98 adapters** — 2 SDK+VM, 34 SDK-only, 59 VM-only, 3 boot-tier. **45 adapters carry derived provider-surface coverage**: their real-API route totals come from the route tables embedded in the pinned official SDKs (Google Discovery docs inside `google-api-go-client`; generated tables inside the Node clients) or from official specs vendored under `conformance/surfaces/` (refreshed by `just surfaces-fetch`) — mechanical and network-free at generation time. For those rows the derived not-implemented list supplements the curated Missing column; adapters without one have no trustworthy machine-readable surface and stay fully curated. @@ -31,13 +31,13 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga |---|---|---|---|---|---|---|---| | [adyen-style](adapters/adyen-style/) | Adyen Checkout + Notification API `v68` | 14 | VM | — | — | [4](#adyen-style) | [8](#adyen-style) | | [anaplan-style](adapters/anaplan-style/) | Anaplan API `2.0` | 18 | VM | — | — | [4](#anaplan-style) | [5](#anaplan-style) | -| [apple-apns-style](adapters/apple-apns-style/) | Apple Push Notification service (APNs) `v2` | 2 | boot | — | — | [4](#apple-apns-style) | [2](#apple-apns-style) | +| [apple-apns-style](adapters/apple-apns-style/) | Apple Push Notification service (APNs) `v2` | 2 | VM | — | — | [4](#apple-apns-style) | [3](#apple-apns-style) | | [apple-appstoreconnect-style](adapters/apple-appstoreconnect-style/) | App Store Connect API `v3` | 15 | VM | — | — | [7](#apple-appstoreconnect-style) | [7](#apple-appstoreconnect-style) | | [apple-music-style](adapters/apple-music-style/) | Apple Music API `1.0` | 18 | VM | — | — | [5](#apple-music-style) | [5](#apple-music-style) | | [apple-searchads-style](adapters/apple-searchads-style/) | Apple Search Ads API `v4` | 12 | VM | — | — | [4](#apple-searchads-style) | [5](#apple-searchads-style) | | [apps-script-style](adapters/apps-script-style/) | Google Apps Script API `v1` | 11 | SDK | google-api-go-client @ v0.293.0 | 6 | [2](#apps-script-style) | [3](#apps-script-style) | | [auth0-style](adapters/auth0-style/) | Auth0 Authentication & Management API `v2` | 17 | VM | — | — | [3](#auth0-style) | [4](#auth0-style) | -| [avalara-style](adapters/avalara-style/) | Avalara AvaTax REST API `2` | 8 | boot | — | — | [5](#avalara-style) | [1](#avalara-style) | +| [avalara-style](adapters/avalara-style/) | Avalara AvaTax REST API `2` | 8 | VM | — | — | [5](#avalara-style) | [3](#avalara-style) | | [aws-cognito-style](adapters/aws-cognito-style/) | Amazon Cognito Identity Provider API `2016-04-18` | 7 | VM | — | — | [6](#aws-cognito-style) | [3](#aws-cognito-style) | | [aws-iam-sts-style](adapters/aws-iam-sts-style/) | AWS STS + IAM API `2011-06-15` | 2 | SDK | aws-sdk-go-v2 @ v1.43.7 | 2 | [3](#aws-iam-sts-style) | [3](#aws-iam-sts-style) | | [aws-s3-style](adapters/aws-s3-style/) | Amazon S3 API `2006-03-01` | 8 | SDK | aws-sdk-go-v2 @ v1.43.7 | 6 | [5](#aws-s3-style) | [5](#aws-s3-style) | @@ -50,19 +50,19 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga | [braze-style](adapters/braze-style/) | Braze REST API `2.0` | 12 | VM | — | — | [8](#braze-style) | [6](#braze-style) | | [chainlink-style](adapters/chainlink-style/) | Chainlink Data Feeds + Functions + Automation `1.0` | 21 | VM | — | — | [6](#chainlink-style) | [7](#chainlink-style) | | [cloudflare-style](adapters/cloudflare-style/) | Cloudflare API `4` | 35 | SDK | cloudflare-go @ v0.117.0 | 7 | [8](#cloudflare-style) | [6](#cloudflare-style) | -| [cloudkit-style](adapters/cloudkit-style/) | CloudKit Web Services API `1` | 5 | boot | — | — | [6](#cloudkit-style) | [2](#cloudkit-style) | +| [cloudkit-style](adapters/cloudkit-style/) | CloudKit Web Services API `1` | 5 | VM | — | — | [6](#cloudkit-style) | [2](#cloudkit-style) | | [discord-style](adapters/discord-style/) | Discord API `v10` | 26 (+1 ws) | SDK | discord-node @ 2.6.3 (floor) | 5 | [8](#discord-style) | [5](#discord-style) | | [drive-style](adapters/drive-style/) | Google Drive API `v3` | 13 | SDK | google-api-go-client @ v0.293.0 | 8 | [7](#drive-style) | [5](#drive-style) | -| [dropbox-style](adapters/dropbox-style/) | Dropbox API `2` | 8 | boot | — | — | [8](#dropbox-style) | [4](#dropbox-style) | -| [dune-style](adapters/dune-style/) | Dune Analytics API `v1` | 6 | boot | — | — | [5](#dune-style) | [3](#dune-style) | +| [dropbox-style](adapters/dropbox-style/) | Dropbox API `2` | 8 | VM | — | — | [8](#dropbox-style) | [4](#dropbox-style) | +| [dune-style](adapters/dune-style/) | Dune Analytics API `v1` | 6 | VM | — | — | [5](#dune-style) | [4](#dune-style) | | [dynamodb-style](adapters/dynamodb-style/) | Amazon DynamoDB API `2012-08-10` | 1 | SDK + VM | aws-sdk-go-v2 @ v1.43.7 | 8 | [8](#dynamodb-style) | [5](#dynamodb-style) | | [echo-style](adapters/echo-style/) | gRPC echo demo `1.0` | 0 (+1 ws) | boot | — | — | — | — | | [emailoctopus-style](adapters/emailoctopus-style/) | EmailOctopus API `2.0.0` | 21 | VM | — | — | [2](#emailoctopus-style) | [3](#emailoctopus-style) | | [entra-id-style](adapters/entra-id-style/) | Microsoft Graph / Entra ID `v1.0` | 9 | SDK | microsoft-graph-client @ 3.0.7 (floor) | 8 | [8](#entra-id-style) | [3](#entra-id-style) | -| [erc4337-style](adapters/erc4337-style/) | ERC-4337 Bundler RPC `0.7` | 2 | boot | — | — | [5](#erc4337-style) | [4](#erc4337-style) | +| [erc4337-style](adapters/erc4337-style/) | ERC-4337 Bundler RPC `0.7` | 2 | VM | — | — | [5](#erc4337-style) | [6](#erc4337-style) | | [escrow-style](adapters/escrow-style/) | Escrow.com API `2017-09-01` | 9 | VM | — | — | [3](#escrow-style) | [4](#escrow-style) | | [eth-jsonrpc-style](adapters/eth-jsonrpc-style/) | Ethereum JSON-RPC `1.0` | 1 | SDK | go-ethereum @ v1.17.5 | 5 | [5](#eth-jsonrpc-style) | [4](#eth-jsonrpc-style) | -| [etherscan-style](adapters/etherscan-style/) | Etherscan API `1.0` | 1 | boot | — | — | [3](#etherscan-style) | [1](#etherscan-style) | +| [etherscan-style](adapters/etherscan-style/) | Etherscan API `1.0` | 1 | VM | — | — | [3](#etherscan-style) | [2](#etherscan-style) | | [fattureincloud-style](adapters/fattureincloud-style/) | Fatture in Cloud API v2 `2.0.29` | 42 | VM | — | — | [3](#fattureincloud-style) | [6](#fattureincloud-style) | | [firebase-style](adapters/firebase-style/) | Firebase Auth + Firestore + Cloud Messaging API `v1` | 22 | VM | — | — | [4](#firebase-style) | [8](#firebase-style) | | [ga4-style](adapters/ga4-style/) | Google Analytics Data API + Admin API `v1beta` | 7 | SDK | google-api-go-client @ v0.293.0 | 7 | [4](#ga4-style) | [3](#ga4-style) | @@ -81,19 +81,19 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga | [hubspot-style](adapters/hubspot-style/) | HubSpot CRM API `v3` | 33 | SDK | hubspot-node @ 14.0.1 (floor) | 5 | [4](#hubspot-style) | — | | [instagram-style](adapters/instagram-style/) | Instagram Graph API `v21.0` | 10 | VM | — | — | [4](#instagram-style) | [5](#instagram-style) | | [jira-style](adapters/jira-style/) | Jira Cloud REST API `3` | 32 | SDK | jira-js @ 6.1.0 (floor) | 7 | [5](#jira-style) | [6](#jira-style) | -| [jumio-style](adapters/jumio-style/) | Jumio API `v1` | 5 | boot | — | — | [3](#jumio-style) | [4](#jumio-style) | -| [linkedin-style](adapters/linkedin-style/) | LinkedIn API `v2` | 8 | boot | — | — | [4](#linkedin-style) | [1](#linkedin-style) | +| [jumio-style](adapters/jumio-style/) | Jumio API `v1` | 5 | VM | — | — | [3](#jumio-style) | [6](#jumio-style) | +| [linkedin-style](adapters/linkedin-style/) | LinkedIn API `v2` | 8 | VM | — | — | [4](#linkedin-style) | [3](#linkedin-style) | | [llm-style](adapters/llm-style/) | OpenAI API + Anthropic API `OpenAI v1 / Anthropic v1` | 3 | SDK | openai-node @ 7.5.0 (floor) | 2 | [4](#llm-style) | [3](#llm-style) | | [marketo-style](adapters/marketo-style/) | Marketo Engage REST API `1.0` | 21 | VM | — | — | [5](#marketo-style) | [3](#marketo-style) | | [microsoft-graph-style](adapters/microsoft-graph-style/) | Microsoft Graph API `v1.0` | 55 | SDK | microsoft-graph-client @ 3.0.7 (floor) | 10 | [8](#microsoft-graph-style) | [6](#microsoft-graph-style) | | [netsuite-style](adapters/netsuite-style/) | NetSuite SuiteTalk REST API `1.0` | 9 | VM | — | — | [4](#netsuite-style) | [3](#netsuite-style) | -| [oneinch-style](adapters/oneinch-style/) | 1inch Aggregation Protocol API `v6.0` | 5 | boot | — | — | [3](#oneinch-style) | [1](#oneinch-style) | -| [onfido-style](adapters/onfido-style/) | Onfido API `v3.6` | 7 | boot | — | — | [5](#onfido-style) | [4](#onfido-style) | -| [opensea-style](adapters/opensea-style/) | OpenSea API `2.0.0` | 7 | boot | — | — | [4](#opensea-style) | [1](#opensea-style) | +| [oneinch-style](adapters/oneinch-style/) | 1inch Aggregation Protocol API `v6.0` | 5 | VM | — | — | [3](#oneinch-style) | [4](#oneinch-style) | +| [onfido-style](adapters/onfido-style/) | Onfido API `v3.6` | 7 | VM | — | — | [5](#onfido-style) | [6](#onfido-style) | +| [opensea-style](adapters/opensea-style/) | OpenSea API `2.0.0` | 7 | VM | — | — | [4](#opensea-style) | [4](#opensea-style) | | [paypal-style](adapters/paypal-style/) | PayPal Orders API `v2` | 17 | VM | — | — | [6](#paypal-style) | [8](#paypal-style) | -| [persona-style](adapters/persona-style/) | Persona Inquiry API `2023-01-05` | 5 | boot | — | — | [5](#persona-style) | [4](#persona-style) | +| [persona-style](adapters/persona-style/) | Persona Inquiry API `2023-01-05` | 5 | VM | — | — | [5](#persona-style) | [6](#persona-style) | | [photos-style](adapters/photos-style/) | Google Photos Library API `v1` | 12 | VM | — | — | [5](#photos-style) | [5](#photos-style) | -| [pinata-style](adapters/pinata-style/) | Pinata API `1.0` | 6 | boot | — | — | [4](#pinata-style) | — | +| [pinata-style](adapters/pinata-style/) | Pinata API `1.0` | 6 | VM | — | — | [4](#pinata-style) | [3](#pinata-style) | | [plaid-style](adapters/plaid-style/) | Plaid API `2020-09-14` | 13 | SDK | plaid-node @ 32.0.0 (floor) | 4 | [5](#plaid-style) | [3](#plaid-style) | | [powerplatform-style](adapters/powerplatform-style/) | Microsoft Power Platform API `2` | 9 | VM | — | — | [4](#powerplatform-style) | [6](#powerplatform-style) | | [printful-style](adapters/printful-style/) | Printful API `v2` | 13 | VM | — | — | [5](#printful-style) | [3](#printful-style) | @@ -101,25 +101,25 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga | [producthunt-style](adapters/producthunt-style/) | Product Hunt GraphQL API `2` | 0 +GQL | boot | — | — | [3](#producthunt-style) | [2](#producthunt-style) | | [psd2-style](adapters/psd2-style/) | Open Banking / PSD2 (Berlin Group NextGenPSD2) `1.3.6` | 19 | VM | — | — | [6](#psd2-style) | [7](#psd2-style) | | [qbo-style](adapters/qbo-style/) | QuickBooks Online API `v3` | 11 | VM | — | — | [6](#qbo-style) | [4](#qbo-style) | -| [reddit-style](adapters/reddit-style/) | Reddit API `1.0` | 2 | boot | — | — | [5](#reddit-style) | [1](#reddit-style) | +| [reddit-style](adapters/reddit-style/) | Reddit API `1.0` | 2 | VM | — | — | [5](#reddit-style) | [3](#reddit-style) | | [resend-style](adapters/resend-style/) | Resend API `1.0.0` | 6 | SDK | resend-node @ 6.22.0 (floor) | 4 | [6](#resend-style) | [3](#resend-style) | -| [revenuecat-style](adapters/revenuecat-style/) | RevenueCat API `v1` | 7 | boot | — | — | [5](#revenuecat-style) | [4](#revenuecat-style) | +| [revenuecat-style](adapters/revenuecat-style/) | RevenueCat API `v1` | 7 | VM | — | — | [5](#revenuecat-style) | [5](#revenuecat-style) | | [salesforce-style](adapters/salesforce-style/) | Salesforce REST API `v60.0` | 29 | SDK | jsforce @ 3.10.22 (floor) | 6 | [7](#salesforce-style) | [4](#salesforce-style) | -| [sendgrid-style](adapters/sendgrid-style/) | Twilio SendGrid v3 API `v3` | 5 | boot | — | — | [7](#sendgrid-style) | [5](#sendgrid-style) | +| [sendgrid-style](adapters/sendgrid-style/) | Twilio SendGrid v3 API `v3` | 5 | VM | — | — | [7](#sendgrid-style) | [6](#sendgrid-style) | | [servicenow-style](adapters/servicenow-style/) | ServiceNow Table API `2` | 44 | VM | — | — | [5](#servicenow-style) | [1](#servicenow-style) | | [shopify-style](adapters/shopify-style/) | Shopify Admin REST + GraphQL API `2024-10` | 21 +GQL | SDK | go-shopify/v4 @ v4.7.0 | 5 | [7](#shopify-style) | [4](#shopify-style) | -| [signin-with-apple-style](adapters/signin-with-apple-style/) | Sign in with Apple `v2` | 3 | boot | — | — | [3](#signin-with-apple-style) | [2](#signin-with-apple-style) | +| [signin-with-apple-style](adapters/signin-with-apple-style/) | Sign in with Apple `v2` | 3 | VM | — | — | [3](#signin-with-apple-style) | [2](#signin-with-apple-style) | | [slack-style](adapters/slack-style/) | Slack Web API `1.0` | 7 | SDK | slack-node @ 7.19.0 (floor) | 4 | [6](#slack-style) | [2](#slack-style) | | [smartbill-style](adapters/smartbill-style/) | SmartBill Cloud API `1.0` | 18 | VM | — | — | [3](#smartbill-style) | [4](#smartbill-style) | | [sqs-style](adapters/sqs-style/) | Amazon SQS API `2012-11-05` | 2 | SDK + VM | aws-sdk-go-v2 @ v1.43.7 | 6 | [4](#sqs-style) | [5](#sqs-style) | | [square-style](adapters/square-style/) | Square API `2024-08-21` | 18 | SDK | square-node @ 45.1.0 (floor) | 4 | [7](#square-style) | [2](#square-style) | | [stripe-style](adapters/stripe-style/) | Stripe API `2025-01-27.acacia` | 158 | SDK | stripe-go/v86 @ v86.3.0
stripe-node @ 22.5.0 (floor) | 9 | [7](#stripe-style) | [5](#stripe-style) | -| [tenderly-style](adapters/tenderly-style/) | Tenderly Simulation API `v1` | 5 | boot | — | — | [3](#tenderly-style) | [2](#tenderly-style) | -| [thegraph-style](adapters/thegraph-style/) | The Graph (GraphQL over subgraphs) `1.0` | 1 +GQL | boot | — | — | [3](#thegraph-style) | [1](#thegraph-style) | +| [tenderly-style](adapters/tenderly-style/) | Tenderly Simulation API `v1` | 5 | VM | — | — | [3](#tenderly-style) | [4](#tenderly-style) | +| [thegraph-style](adapters/thegraph-style/) | The Graph (GraphQL over subgraphs) `1.0` | 1 +GQL | VM | — | — | [3](#thegraph-style) | [2](#thegraph-style) | | [threads-style](adapters/threads-style/) | Threads API (Meta) `v1.0` | 8 | VM | — | — | [3](#threads-style) | [6](#threads-style) | | [twilio-style](adapters/twilio-style/) | Twilio API `2010-04-01` | 6 | SDK | twilio-go @ v1.30.9
twilio-node @ 6.1.0 (floor) | 8 | [6](#twilio-style) | [4](#twilio-style) | | [twitter-style](adapters/twitter-style/) | Twitter/X API `v2` | 9 | VM | — | — | [6](#twitter-style) | [5](#twitter-style) | -| [walletconnect-style](adapters/walletconnect-style/) | WalletConnect (Relay Protocol v2) `2.0` | 7 | boot | — | — | [3](#walletconnect-style) | [2](#walletconnect-style) | +| [walletconnect-style](adapters/walletconnect-style/) | WalletConnect (Relay Protocol v2) `2.0` | 7 | VM | — | — | [3](#walletconnect-style) | [5](#walletconnect-style) | | [whatsapp-style](adapters/whatsapp-style/) | WhatsApp Business Cloud API (Meta) `v21.0` | 8 | VM | — | — | [5](#whatsapp-style) | [7](#whatsapp-style) | | [workday-style](adapters/workday-style/) | Workday REST API `v40.0` | 8 | VM | — | — | [4](#workday-style) | [3](#workday-style) | | [x-articles-style](adapters/x-articles-style/) | X (Twitter) Articles API `v2` | 8 | VM | — | — | [4](#x-articles-style) | [4](#x-articles-style) | @@ -602,6 +602,14 @@ Named by their `// =====` section markers. - catalog ids resolve only within their model - task status is scoped to its workspace and model +**apple-apns-style** + +- the provider token gate distinguishes missing invalid and expired tokens +- a push to the known device returns 200 with a canonical uuid apns-id +- unknown device tokens are 400 baddevicetoken +- empty aps payloads are 400 payloadempty +- sent notifications are retrievable per device + **apple-appstoreconnect-style** - a rejected credential answers Apple's 401 errors array, not a bare status @@ -707,6 +715,28 @@ Named by their `// =====` section markers. - signup creates an unverified user the Management API can see - duplicate, weak, and unknown-client signups are rejected +**avalara-style** + +- every v2 endpoint demands a credential: a bare call is a 401 AuthenticationRequired envelope +- any Bearer or any HTTP Basic credential opens the gate +- a non-Basic/Non-Bearer scheme does not count as a credential +- the effective rate keys off the address state (CA 0.095) with a State/County/City/Special breakdown +- the summary aggregates the taxable base per jurisdiction +- per-line tax rounds to cents: two lines aggregate, line 2 keeps its own tax +- SDK decimal strings ("100.00") price identically to JSON numbers +- the shipFrom/shipTo form keys off shipTo (NY 0.0875) +- unknown or missing addresses fall back to the synthetic 0.0825 default +- create prices the document, mints id/code/companyId and applies AvaTax defaults +- an omitted date defaults to the clock's today, and advances with it +- read round-trips by id; unknown ids are 404 NotFound +- the list supports OData $filter and $orderBy with @recordsetCount +- $top/$skip pages through an @odata.nextLink that round-trips +- void flips status to Cancelled and the record reads back cancelled +- re-void is idempotent +- the companies catalog lists DEFAULT and STORE1 with default locations +- nexus $filter literals are typed: id eq 1001 matches ints, hasNexus eq true matches bools +- the taxcode catalog is filterable by taxCode + **aws-cognito-style** - a reset code older than one hour answers ExpiredCodeException @@ -833,6 +863,45 @@ Named by their `// =====` section markers. - lane status echoes the requested pair with its ramp addresses - the seeded test token dies after its ten-year virtual window +**cloudkit-style** + +- the s2s signature gate rejects unsigned tampered stale and foreign-key requests +- users current returns the s2s owner identity +- zones list seeds defaults filters by prefix and pages +- records lookup returns the seeded shape with inline NOT_FOUND +- records query filters sorts and pages on a numeric resultsLimit +- records modify creates updates and deletes round-trip + +**dropbox-style** + +- upload takes the JSON {path, content} convenience body (documented deviation) +- the real RPC upload (Dropbox-API-Arg header + raw octet-stream body) lands identically +- re-uploading an existing path answers the real mode:"add" conflict +- mode overwrite replaces in place; autorename forks a suffixed path +- list_folder returns the whole path-prefix subtree, not one level +- the root listing spans everything; unknown and file paths carry distinct 409 tags +- paging slices the filtered subtree by body cursor, ignoring query strings +- download streams raw bytes by path and by id, with metadata alongside +- folders and unknown paths decline under the 409 path envelope +- get_temporary_link pairs the file's metadata with the synthetic link +- a presented bearer must be registered: unknown and expired tokens get distinct 401 tags +- an absent Authorization header stays open (documented deviation) +- create_folder mints folder metadata and conflicts case-insensitively +- get_current_account returns the synthetic /2/users snapshot +- deleting a folder removes its entire subtree from every read path +- trash tombstones audit the exact cascade batch +- delete is permanent: a re-created path is a brand-new entry + +**dune-style** + +- the api-key gate rejects missing, empty and non-bearer authorization +- an execution walks PENDING -> EXECUTING -> COMPLETED as the clock advances +- a missing required parameter is the 400 envelope and both SDK parameter shapes resolve +- simulate_fail terminates QUERY_STATE_FAILED and results carry the failure envelope +- the inline-result route completes synchronously +- results pages honor limit/offset with a followable next_uri +- the CSV variant streams text/csv for the same page + **dynamodb-style** - an unsigned request is 403; a real SigV4 signature passes @@ -869,6 +938,16 @@ Named by their `// =====` section markers. - get by contact id round-trips the email - delete removes the contact; reads 404 after +**erc4337-style** + +- supportedEntryPoints, chainId, and the JSON-RPC envelope +- estimateUserOperationGas validates the full v0.7 field set +- sendUserOperation answers a deterministic hash and defaults the entry point +- the op walks mempool -> bundled -> included on the virtual clock +- simulate_fail reverts on inclusion with the AA95 reason +- the paymaster signs the op into paymasterAndData +- missing or invalid userOps are 400s + **escrow-style** - missing, Bearer, and malformed Authorization are 401s with the challenge header @@ -903,6 +982,15 @@ Named by their `// =====` section markers. - the list carries every registered webhook - a missing or blank url is the can't-be-blank field error +**etherscan-style** + +- the apikey query parameter gates every module call +- unknown modules and actions answer the NOTOK envelope over HTTP 200 +- balance reads the seeded ledger; unknown addresses default to "0" +- txlist scopes by address then applies block filters, sort and paging +- contract verification: ABI, source, and the unverified fallback +- stats and token holders keep every number a decimal string + **fattureincloud-style** - auth failures answer the flat OAuth envelope; any non-empty bearer passes @@ -1020,6 +1108,72 @@ Named by their `// =====` section markers. - refresh mints a fresh 60-day token; the old one keeps working - the refreshed token outlives the original's expiry +**jumio-style** + +- a missing, bare or wrong-scheme token is a 401 in the Jumio error envelope +- a scan create answers PENDING with a synthetic decimal scan reference +- sequential creates advance the reference sequence +- a create without merchantScanReference is a 400 +- PENDING holds through the processing window then flips to DONE +- a FAILED scan carries a real reject reason and its description +- unknown scans are 404 on every parameterized route +- extracted data is None while the scan is PENDING +- DONE exposes the synthetic document extraction +- FAILED scans answer data with a 409 repeating the reason +- delete removes the scan and later reads are 404s +- deleting after the terminal window still advances the lifecycle +- a correctly MACed webhook body is accepted +- a tampered body, wrong MAC or missing header is a 401 +- the terminal transition emits exactly one signed scan.completed +- failed scans emit scan.failed carrying the rejection reason +- a delete-driven terminal transition also emits, then nothing more + +**linkedin-style** + +- authorize without redirect_uri, state or client_id is invalid_request +- authorize redirects back with a fresh code and the state echoed +- a redirect_uri that already carries a query is joined with & +- the exchange demands grant_type=authorization_code +- an unknown code is 400 invalid_grant +- a good exchange mints a 60-day token pair for a fresh member +- the code is single-use: a replay is invalid_grant +- client mismatches are 400 invalid_client +- a mismatched attempt must not burn the code +- a second flow mints a distinct member +- the refresh grant demands client creds +- an unknown refresh token is invalid_grant +- refresh rotates the pair and keeps the member +- the presented refresh token is single-use +- rotation chains: the new refresh token refreshes again +- a missing bearer is 401 in the service error envelope +- wrong schemes and unknown bearers answer the same 401 +- every API route enforces the same gate +- a bearer dies at its clock-derived 60-day expiry +- userinfo returns the OAuth member profile +- publishing as anyone but the token's member is a 403 +- a good publish mints a ugcPost urn echoed in x-linkedin-id +- the post resolves to a share urn carrying its own author +- resolving an unknown urn is a 404 +- unconfigured, publishing is unthrottled +- arming fail_after injects 429 REQUEST_LIMIT_EXCEEDED +- the limit is per member +- a throttled attempt creates no post +- q must be author +- reply resolves urn:li:person:me to the authenticated member +- commenting as anyone but the caller is a 403 +- replying to an unknown object is a 404 +- ingest lists only the token member's comments +- member B's comment resolved me and lists only under B +- createdOn is clock-stamped and monotonic +- count pages with a next link that round-trips the query +- without count the whole list returns unpaged +- a malformed start cursor is a 400 +- an unknown entity is a 404 +- each queryType totals base+3/5/7/11 split across two daily buckets +- entity accepts both the parenthesized and bare urn forms +- start past the data returns an empty page +- an unknown queryType falls back to the base total (deviation, as-is) + **marketo-style** - only client_credentials mints a token @@ -1070,6 +1224,45 @@ Named by their `// =====` section markers. - request-body fields override the mapped defaults - impossible chains, unknown sources and dangling overrides use NetSuite's real codes +**oneinch-style** + +- a quote returns token pairs, a decimal toAmount and a 100-point split +- quotes are deterministic and address matching is case-insensitive +- the toAmount scales linearly with the input amount +- a same-token quote scales the amount by the pseudo-rate (as-is) +- missing params and unknown tokens are 400 error envelopes +- a swap returns router-addressed calldata with gas and gasPrice +- the swap toAmount matches the quote for the same input +- slippage is optional and ignored (as-is) +- missing params and unknown tokens are 400s +- the spender is the router contract address +- approve calldata targets the token with the max allowance +- a missing or unknown token is a 400 +- the token list is an address-keyed map of six tokens +- every token in the list is quotable as a source + +**onfido-style** + +- a missing or non-Token Authorization header is 401 authorization_error +- applicant create flags exactly the blank names and reads back by id +- document and live photo uploads bind to a real applicant and default side +- check create demands report_names and a known applicant +- the check completes from the clock and emits check.completed exactly once +- simulate_fail completes with consider and consider breakdowns +- the webhook receiver MACs the exact raw bytes + +**opensea-style** + +- the X-API-KEY gate 401s every surface with the V1ErrorWrapper envelope +- the asset list seeds five mock-punks NFTs and filters by collection_slug +- single-asset reads match the address case-insensitively and 404 unknown shapes +- collections read back contracts and string-typed stats; unknown slugs 404 +- limit/next cursor pagination walks the pages and 400s a malformed cursor +- events filter by collection_slug and event_type +- listings carry the Seaport ask shape: the NFT in offer, payment in consideration +- offers invert the shape: payment in offer, the NFT in consideration +- created offers are stateful, deterministic, and defaulted + **paypal-style** - client_credentials over Basic auth mints a distinct Bearer with PayPal's token envelope @@ -1110,6 +1303,27 @@ Named by their `// =====` section markers. - signature verification answers SUCCESS only for known webhook ids - deletion is 204, and repeats are 404 +**persona-style** + +- a missing, bare or wrong-scheme token is a 401 in the JSON:API error envelope +- a create mints a zero-padded inq_ id in the JSON:API envelope +- sequential creates advance the id sequence +- any Bearer is accepted: the gate checks presence, not a store (as-is) +- a create missing template_id or reference_id is a 400 invalid_request +- the status derives from the clock created to pending to completed +- resume restarts the clock at pending without duplicating verifications +- simulate_fail declines at the terminal transition and seeds nothing +- unknown inquiries are JSON:API 404s on every parameterized route +- verifications are empty until the terminal transition fires +- completion seeds the government-id and selfie verifications +- a fresh correctly-signed webhook is accepted +- a tampered body or wrong MAC is a 401 invalid_signature +- a stale or far-future t is a 401 invalid_timestamp +- a missing header or unparseable signature is a 401 +- polling through pending still emits exactly one inquiry.completed +- re-reads and post-resume re-completions do not re-emit +- a declined inquiry emits inquiry.declined signed the same way + **photos-style** - authorize without redirect_uri, state or client_id is 400 invalid_request @@ -1163,6 +1377,19 @@ Named by their `// =====` section markers. - albums are private to their user - delete removes the album but leaves its media +**pinata-style** + +- missing or half-present credentials are 401 with the error envelope +- the API key pair and a Bearer JWT both open testAuthentication +- pinJSONToIPFS pins content to a real CIDv0 +- re-pinning identical content is isDuplicate, not a new pin +- pinFileToIPFS sizes and names the pin from the multipart parts +- pinList filters by hash, size, status, and metadata name +- pinStart/pinEnd bound the date-pinned window +- pinByHash requires hash and matches the CID exactly +- pinList pages at the real default of 10 rows +- unpin removes the CID; a second unpin is 403 FORBIDDEN + **powerplatform-style** - every route sits behind the entra bearer gate: a missing, non-bearer, or empty token answers 401 in the microsoft error envelope @@ -1276,6 +1503,38 @@ Named by their `// =====` section markers. - an unknown entity answers an empty QueryResponse; a statement without one is a 400 fault - STARTPOSITION pages with MAXRESULTS: row 2 of the sorted set +**reddit-style** + +- a missing or generic User-Agent is 429 on both routes +- the token endpoint requires HTTP Basic client credentials +- a permanent authorization_code mints access and refresh together +- a refresh grant returns a fresh access token and no new refresh +- submit requires a bearer the adapter itself minted +- a valid submit returns the t3_ thing envelope +- missing sr or title stay HTTP 200 with Reddit error triples +- an access token dies after its one-hour window + +**revenuecat-style** + +- a missing or unknown key is a 401 {code, message} envelope +- the public pk_ SDK key passes subscriber reads and receipt posts but is 401 on restricted writes +- GET subscriber is get-or-create and answers in the v1 CustomerInfo envelope +- receipt validation mirrors the real 400 order: app_user_id, platform, fetch_token, bad token +- an ios receipt grants the pro entitlement with real trial math, and renewals stack +- a google-play dict receipt feeds the product and lands in non_subscriptions +- revoke lapses a live subscription; delete and the 404 envelopes +- expiry is derived on read: a lapsed trial drops its entitlement + +**sendgrid-style** + +- the bearer gate rejects missing and unknown keys with SendGrid's grant envelope +- mail send answers 202 with an empty body, an X-Message-Id, and flattened personalizations +- the retrieval endpoint pages with limit and the opaque offset cursor +- the delivery lifecycle derives processed -> delivered (or dropped) on read, exactly once +- the Email Activity query language narrows the list +- event webhook settings round-trip and require a URL when enabled +- deliveries are ECDSA P-256 signed over timestamp + raw body and fire once per recipient stage + **servicenow-style** - unknown credentials are 401 at the gate @@ -1288,6 +1547,14 @@ Named by their `// =====` section markers. - unknown sys_id and unknown table are 404 - delete removes the record for good +**signin-with-apple-style** + +- authorize redirects with a single-use code plus state and validates its params +- the token exchange mints a real es256 id_token with apple claim shapes +- the served jwks verifies the minted id_token signature +- auth codes are single-use and client_secrets are verified cryptographically +- the refresh grant rotates access tokens and rejects stale or foreign inputs + **smartbill-style** - requests without credentials are turned away with the errorText envelope @@ -1339,6 +1606,24 @@ Named by their `// =====` section markers. - DeleteQueue tears the queue down for its messages too - the throttled profile alternates empty receives deterministically +**tenderly-style** + +- the access-key gate rejects missing and unknown bearers with the slug envelope +- networks answer the bare array and switch to the paged envelope under perPage +- a plain simulation round-trips the deterministic Tenderly shape +- a value transfer emits the ERC-20 Transfer log and balance overrides +- reverting simulations carry the ABI-encoded Error(string) output +- bundles fan out per simulation and stored results list and retrieve by id + +**thegraph-style** + +- pools collection arguments sort by volume and join token0/token1 +- where filters map the graph-node suffix operators +- validation failures and the first cap surface as GraphQL errors +- domains join owner/resolvedAddress; lookups miss as null +- _meta reports the deployment head; Token.pools joins in reverse +- the REST SDL surface is public and rejects unknown bearer keys + **threads-style** - authorize without redirect_uri, state or client_id is invalid_request @@ -1396,6 +1681,20 @@ Named by their `// =====` section markers. - max_results pages via meta.next_token; an invalid pagination_token is a 400 - the tweet list shares the same v2 paging and stays unpaged without max_results +**walletconnect-style** + +- every route answers without a projectId (the gate is not wired) +- a wc: URI pairing round-trips its topic, relay protocol, and symKey +- an auto pairing mints a fresh topic and a 64-hex symKey +- proposing requires pairingTopic — and accepts one never paired +- approve acknowledges the session and derives eip155 namespaces +- the session list is a bare array capped by limit +- the approval gate is missing: an unacknowledged session still answers +- wallet requests answer in a JSON-RPC 2.0 envelope with monotonic ids +- signing and transaction methods return synthetic 0x-hex hashes +- extend echoes the fixed session TTL without persisting anything +- disconnect retires the topic and every later call 404s + **whatsapp-style** - a missing bearer is 401 in the Meta error envelope @@ -1700,10 +1999,11 @@ behavior notes live in each adapter's README. - No 429 TooManyRequests or 503 ServiceUnavailable rate-limit responses - No Web Push endpoints (Safari webpush) or VOIP push handling -**Deviations** (2) +**Deviations** (3) - Provider JWT verified against one fixed P-256 key whose private half is published - GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API) +- sent_at on the notifications endpoint is a constant; 410 Unregistered is modeled but unreachable
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -2174,9 +2474,11 @@ behavior notes live in each adapter's README. - No customers, certificates, or exemption endpoints - No jurisdiction lookups — definitions serve nexuses and taxcodes only -**Deviations** (1) +**Deviations** (3) - Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate +- Void returns a minimal {id, status} envelope; real AvaTax returns the full TransactionModel +- Re-void is idempotent 200; real AvaTax rejects voiding a Cancelled document
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -3490,11 +3792,12 @@ behavior notes live in each adapter's README. - No CSV transfer to external storage (transfers endpoints) - No table metadata endpoints (GET /api/v1/table/{namespace}/{table}) -**Deviations** (3) +**Deviations** (4) - executions follow a fixed clock: EXECUTING at +1s, COMPLETED at +3s after execute - queries come from a static 3-entry catalog; no real SQL is executed - simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger +- Auth is a presence-only Bearer; real Dune uses the x-dune-api-key header
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -3756,12 +4059,14 @@ behavior notes live in each adapter's README. - No debug_bundler_* ops: sendBundleNow, clearState, dropUserOperation - No WebSocket transport or newHeads subscriptions -**Deviations** (4) +**Deviations** (6) - gas estimates are deterministic fixed values; real bundlers differ per implementation - inclusion runs on a fixed clock: mempool 0-1s, bundled 1-3s, included at >=3s - eth_sendUserOperation accepts {simulate_fail:true} as a third params element - mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures +- userOp keeps the v0.6 paymasterAndData field though the adapter is EntryPoint-v0.7-only +- Error envelopes use generic -32602 rather than AA-prefixed codes (-32500...)
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -3864,13 +4169,14 @@ behavior notes live in each adapter's README. **Missing** (3) -- No txlistinternal, tokentx, or getLogs actions +- No getLogs action (txlistinternal/tokentx are served, hard-coded empty) - No logs, proxy, or gastracker modules (gasoracle, eth_call passthrough) - No getminedblocks, getblocknobytime, or nodecount actions -**Deviations** (1) +**Deviations** (2) - Auth accepts any non-empty apikey; only a missing key yields the error envelope +- txlistinternal/tokentx/tokenbalance return hard-coded empty results
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -5543,12 +5849,14 @@ behavior notes live in each adapter's README. - No hosted redirect verification flow; API scan creation only - No Document Verification, Data Verification, or Transaction Screening APIs -**Deviations** (4) +**Deviations** (6) - Scan lifecycle on a fixed clock: PENDING ~3s then DONE or FAILED at +3s - simulate_fail and simulate_reject_reason create fields are stunt-only hooks - Webhook HMAC secret is the public constant stunt_jumio_mock_signing_key - POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint +- Bearer-presence gate; real Jumio uses HTTP Basic against a server-token store +- Scan references are decimal groups, not UUIDs; extracted PII is fixed synthetic
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -5586,9 +5894,11 @@ behavior notes live in each adapter's README. - No reactions or likes endpoints (socialActions likes) - No video upload flow or multi-image carousel posts -**Deviations** (1) +**Deviations** (3) - Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook +- An unknown analytics queryType silently falls back to the base total; real LinkedIn 400s +- Refresh tokens never expire; only access tokens carry the 60-day expiry
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -6043,9 +6353,12 @@ behavior notes live in each adapter's README. - No Limit Order Protocol endpoints (create, list, history) - No raw transaction broadcast, status check, or chains list endpoints -**Deviations** (1) +**Deviations** (4) - Quotes deterministic from src/dst/amount; same input yields same toAmount and split +- Quote field is toAmount; real v6.0 returns dstAmount +- Same-token quotes return amount x pseudo-rate; real API rejects +- slippage is optional and ignored on swap; real v6.0 requires it
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -6142,12 +6455,14 @@ behavior notes live in each adapter's README. - No motion capture or video uploads; no document/live photo list or delete - No webhook registration or listing (local receiver only) -**Deviations** (4) +**Deviations** (6) - Check lifecycle fixed: in_progress ~3s then complete; awaiting_applicant skipped - simulate_fail yields result consider; real sandbox uses special sandbox documents - Webhook HMAC secret is the public constant stunt_onfido_mock_signing_key - POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint +- Synthetic sequential ids (app-000001...) where real Onfido uses UUIDs +- The awaiting_applicant phase is skipped; documents assumed on file
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -6245,9 +6560,12 @@ behavior notes live in each adapter's README. - No account lookup (GET /api/v2/accounts/{address}) - No NFT transfer history endpoint (chain/{chain}/transfers) -**Deviations** (1) +**Deviations** (4) - X-API-KEY accepted as any non-empty value; no real key validation +- Asset routes serve the deprecated v2 surface; real v2 replaced them with collection-scoped nfts endpoints +- Identical offers re-create and store duplicate orders (no order_hash dedupe) +- Create-offer body and response are simulator-specific, not the documented criteria-offer shapes
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -6427,12 +6745,14 @@ behavior notes live in each adapter's README. - No Transactions API (bank account linking and verification) - No inquiry cancel, expire, redact, or mark-under-review actions -**Deviations** (4) +**Deviations** (6) - Fixed clock lifecycle: created (0-1s), pending (1-3s), completed or declined (+3s) - simulate_fail create flag yields declined instead of completed (stunt-only) - Webhook HMAC secret is the public constant stunt_persona_mock_signing_key - POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window +- JSON:API attributes are snake_case; real Persona serializes kebab-case +- Create takes a flat body; real API expects the JSON:API data/attributes wrapper
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -6524,10 +6844,16 @@ behavior notes live in each adapter's README. - No metadata update or pin-policy change (POST /pinning/hashMetadata) - No pinned-data-usage endpoint (GET /data/userPinnedDataTotal) +**Deviations** (3) + +- Credentials are presence-checked only; any non-empty key pair or Bearer JWT passes +- Every stored pin is status pinned — unpinned/pending/failed not modeled (no job queue) +- JSON pin CIDs derive from stunt canonical serialization, not byte-identical to real Pinata +
Derived behavior tags (static — from scripts/*.star, not SDK-verified) -- `POST` `/pinning/pinFileToIPFS` — body, stateful -- `POST` `/pinning/pinJSONToIPFS` — body, stateful +- `POST` `/pinning/pinFileToIPFS` — stateful, clock +- `POST` `/pinning/pinJSONToIPFS` — body, stateful, clock - `DELETE` `/pinning/unpin/{cid}` — params, stateful - `GET` `/data/pinList` — query, stateful, filter - `GET` `/data/testAuthentication` — — @@ -7075,9 +7401,11 @@ behavior notes live in each adapter's README. - No token revocation (POST /api/v1/revoke_token) - No subreddit, search, or inbox/messages endpoints -**Deviations** (1) +**Deviations** (3) - User-Agent gate simplified: any UA containing / and ( passes, else 429 +- The authorization_code grant never validates code or redirect_uri — any or missing code mints tokens +- Post ids are plain sequence strings, not Reddit base36
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -7198,12 +7526,13 @@ behavior notes live in each adapter's README. - Only ios and android platforms — no amazon, stripe, or web billing - No v2 REST surface (customers, subscriptions, entitlements) -**Deviations** (4) +**Deviations** (5) -- Webhook registration endpoint is simulator-only; real RC v1 webhooks are dashboard-configured - fetch_token prefixed with invalid is the deterministic bad-receipt 400 path - Subscription expiry is derive-on-read; first read past it fires EXPIRATION - POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests +- pk_ public keys are simulator convention; real RC public keys are appl_/goog_-prefixed +- REST webhook registration does not exist in real RC v1 (dashboard-configured)
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -7363,19 +7692,20 @@ behavior notes live in each adapter's README. - No sender authentication or domains setup - No templates surface (/v3/templates) -**Deviations** (5) +**Deviations** (6) - Webhook ECDSA signature is raw r||s (64 bytes), not Twilio ASN.1 DER encoding - simulate_fail: true in send body forces dropped terminal (simulator extension) - delivered derives at fixed +3s on first list read, not real async delivery -- Webhook deliveries are single stunt-enveloped objects; real SendGrid POSTs a JSON array - Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields +- Each delivery wraps one event in the transport envelope; real SendGrid batches a JSON array of events +- asm, sandbox_mode, and batch_id are accepted but not modeled
Derived behavior tags (static — from scripts/*.star, not SDK-verified) - `POST` `/v3/mail/send` — body, auth, stateful, webhooks, clock - `GET` `/v3/messages` — query, auth, stateful, paginate, filter, webhooks, errors, clock -- `POST` `/v3/user/webhooks/event/settings` — body, auth, stateful, clock +- `POST` `/v3/user/webhooks/event/settings` — body, auth, stateful, errors, clock - `GET` `/v3/user/webhooks/event/settings` — auth, stateful, clock - `POST` `/v3/user/webhooks/event/test` — auth, stateful, webhooks, errors, clock @@ -8365,10 +8695,12 @@ behavior notes live in each adapter's README. - No alerts or notification-rules API - No transaction lookup/list endpoints (transactions by hash) -**Deviations** (2) +**Deviations** (4) - gas_used is derived from input length, not real EVM execution; status defaults to true - An explicit revert:true body flag forces the revert path; real API has no such switch +- Bearer auth where real Tenderly uses the X-Access-Key header +- Responses mix camelCase where Tenderly is snake_case and nests simulation.id
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -8398,9 +8730,10 @@ behavior notes live in each adapter's README. - No indexing-status queries beyond _meta (no /status endpoint) - No GraphQL subscriptions over websockets; HTTP queries only -**Deviations** (1) +**Deviations** (2) - One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment +- _meta.block.number advances per query; the real head is block-height driven
Derived behavior tags (static — from scripts/*.star, not SDK-verified) @@ -8692,10 +9025,13 @@ behavior notes live in each adapter's README. - No session update, ping, or event emission (chainChanged/accountsChanged) - No pairing list, expire, or ping operations -**Deviations** (2) +**Deviations** (5) - Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device) - personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed +- The projectId gate ships but is never wired — every route answers without a credential +- Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing +- Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)
Derived behavior tags (static — from scripts/*.star, not SDK-verified) diff --git a/adapters/apple-apns-style/scripts/lib.star b/adapters/apple-apns-style/scripts/lib.star index c704b174..bdfaac92 100644 --- a/adapters/apple-apns-style/scripts/lib.star +++ b/adapters/apple-apns-style/scripts/lib.star @@ -169,13 +169,15 @@ def _provider_token_expired(claims): return True # _require_jwt returns the token if valid, or an error response if not. -# Distinct reasons per real APNs: a present-but-expired provider token is -# 403 ExpiredProviderToken; anything else unusable is 403 (bad/missing -# provider auth). +# Distinct reasons per real APNs: no bearer header at all is 403 +# MissingProviderToken; a present-but-expired provider token is 403 +# ExpiredProviderToken; anything else unusable is 403 InvalidProviderToken. def _require_jwt(req): auth = req["headers"].get("Authorization", "") if auth == "": auth = req["headers"].get("authorization", "") + if auth == "": + return None, respond(403, {"reason": "MissingProviderToken"}) token, expired = _verify_provider_token(auth) if token == None: if expired: @@ -223,12 +225,16 @@ def _mint_jwt(header_json, payload_json): # --- APNs helpers --- -# _generate_apns_id creates a synthetic APNs ID (UUID-like). +# _generate_apns_id creates a synthetic APNs ID in canonical UUID form +# (8-4-4-4-12 hex; real APNs returns a canonical UUID when the request omits +# apns-id). Derived from the sequence, so ids stay unique per notification. def _generate_apns_id(): seq = store_kv_incr("apns", "apns_id_seq") - # Format as a UUID-like string. - s = str(0x10000000 + seq) - return s + "-0000-0000-0000-0000000000" + str(seq)[-3:] + h = "%x" % seq + # % has no zero-padding verb here, so pad by hand to 32 hex digits. + while len(h) < 32: + h = "0" + h + return h[:8] + "-" + h[8:12] + "-4" + h[13:16] + "-8" + h[17:20] + "-" + h[20:32] # _seed populates default device tokens on first access. def _seed(): diff --git a/adapters/apple_apns_style_test.go b/adapters/apple_apns_style_test.go new file mode 100644 index 00000000..1ba15559 --- /dev/null +++ b/adapters/apple_apns_style_test.go @@ -0,0 +1,289 @@ +package adapters + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "os" + "path/filepath" + "regexp" + "strconv" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the apple-apns-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: the provider-token gate (real ES256 +// verification with distinct reasons for missing/invalid/expired tokens), +// the POST /3/device/{token} push shape (200 + canonical-UUID apns-id, +// BadDeviceToken, Unregistered-shaped errors, PayloadEmpty) and the internal +// per-device notifications retrieval. +const ( + apnsVMHost = "api.push.apple.test" + apnsVMKnownToken = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + apnsVMNewToken = "0000000000000000000000000000000000000000000000000000000000000000" +) + +// apnsVMPrivPEM mirrors the adapter's documented synthetic provider keypair +// (README): the public half is baked into lib.star, the private half signs +// provider tokens here exactly the way a real APNs provider key would. +const apnsVMPrivPEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgz399eDP4CEo1JoR7 +A5uHueHShJKhKvna8BiAvVQPkvyhRANCAAS6OMBYKYI6moCMo0FeQ23CAvQMT5sy +MZrf7jMKmvhmI/aMJuodNWq4eLSq6/X4oWriaY7RsKxIrQ5F/Ql+y6XJ +-----END PRIVATE KEY-----` + +// apnsVMUUID pins the canonical 8-4-4-4-12 hex apns-id real APNs returns. +var apnsVMUUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +type apnsVMFixture struct { + t *testing.T + vc *clock.Clock + vm *starlark.VM + host string +} + +func newApnsVMFixture(t *testing.T, start time.Time) *apnsVMFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "apple-apns-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + src, err := os.ReadFile(filepath.Join(root, "scripts", "send.star")) + if err != nil { + t.Fatalf("read send.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib send.star: %v", err) + } + return &apnsVMFixture{t: t, vc: vc, vm: vm, host: apnsVMHost} +} + +// call drives a handler on the device route; auth "" sends no Authorization +// header at all (the missing-token case). +func (f *apnsVMFixture) call(handler, method, path, token string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vm.Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, + Params: map[string]string{"deviceToken": token}, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// send is the common push: an alert+badge aps payload to the given token. +func (f *apnsVMFixture) send(token, auth string) starlark.Response { + f.t.Helper() + return f.call("on_send", "POST", "/3/device/"+token, token, map[string]any{ + "aps": map[string]any{ + "alert": map[string]any{"title": "Test Push", "body": "Hello from stunt!"}, + "badge": 1, + }, + }, auth) +} + +// apnsVMSign signs a compact-JSON header/payload pair as a real ES256 JWT +// (raw r||s signature) with the given key. +func apnsVMSign(t *testing.T, priv *ecdsa.PrivateKey, header, payload string) string { + t.Helper() + h := base64.RawURLEncoding.EncodeToString([]byte(header)) + p := base64.RawURLEncoding.EncodeToString([]byte(payload)) + digest := sha256.Sum256([]byte(h + "." + p)) + r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// apnsVMKey parses the documented provider private key PEM. +func apnsVMKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + block, _ := pem.Decode([]byte(apnsVMPrivPEM)) + if block == nil { + t.Fatal("bad test key PEM") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + t.Fatalf("parse test key: %v", err) + } + priv, ok := key.(*ecdsa.PrivateKey) + if !ok { + t.Fatal("test key is not ECDSA") + } + return priv +} + +// apnsVMMint mints a provider token: header {alg,kid}, payload +// {iss,iat[,exp]} — signed with the documented key. +func apnsVMMint(t *testing.T, priv *ecdsa.PrivateKey, iat, exp int64) string { + t.Helper() + header := `{"alg":"ES256","kid":"mock-apns-key-1"}` + payload := `{"iss":"MOCKTEAMID","iat":` + strconv.FormatInt(iat, 10) + if exp > 0 { + payload += `,"exp":` + strconv.FormatInt(exp, 10) + } + return apnsVMSign(t, priv, header, payload+"}") +} + +// wantReason asserts an APNs error envelope {reason: ...}. +func wantReason(t *testing.T, r starlark.Response, status int, reason, label string) { + t.Helper() + if r.Status != status { + t.Fatalf("%s -> %d, want %d; body %v", label, r.Status, status, r.Body) + } + if r.Body["reason"] != reason { + t.Fatalf("%s reason = %v, want %s", label, r.Body["reason"], reason) + } +} + +func TestAppleApnsVMProviderTokenGate(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newApnsVMFixture(t, base) + priv := apnsVMKey(t) + fresh := "Bearer " + apnsVMMint(t, priv, base.Unix(), 0) + + // ===== the provider token gate distinguishes missing invalid and expired tokens ===== + // No authorization header at all: real APNs reports MissingProviderToken. + wantReason(t, f.send(apnsVMKnownToken, ""), 403, "MissingProviderToken", "no auth header") + // Garbage bearer value. + wantReason(t, f.send(apnsVMKnownToken, "Bearer not-a-jwt"), 403, "InvalidProviderToken", "garbage token") + // A JWT whose JOSE header claims a symmetric alg. + hs256 := "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJNT0NLVEVBTUlEIn0.c2ln" + wantReason(t, f.send(apnsVMKnownToken, "Bearer "+hs256), 403, "InvalidProviderToken", "HS256 alg") + // A well-formed token signed by an unregistered key. + forged, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + wantReason(t, f.send(apnsVMKnownToken, "Bearer "+apnsVMMint(t, forged, base.Unix(), 0)), + 403, "InvalidProviderToken", "forged signature") + // iat 2h old (APNs caps provider tokens at 1h when exp is absent). + wantReason(t, f.send(apnsVMKnownToken, "Bearer "+apnsVMMint(t, priv, base.Unix()-7200, 0)), + 403, "ExpiredProviderToken", "stale iat") + // An explicit exp in the past expires the token. + wantReason(t, f.send(apnsVMKnownToken, "Bearer "+apnsVMMint(t, priv, base.Unix()-7200, base.Unix()-3600)), + 403, "ExpiredProviderToken", "explicit past exp") + // A fresh iat (exp absent, within the 1h cap) goes through. + if r := f.send(apnsVMKnownToken, fresh); r.Status != 200 { + t.Fatalf("fresh provider token -> %d, want 200; body %v", r.Status, r.Body) + } +} + +func TestAppleApnsVMDevicePush(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newApnsVMFixture(t, base) + auth := "Bearer " + apnsVMMint(t, apnsVMKey(t), base.Unix(), 0) + + // ===== a push to the known device returns 200 with a canonical uuid apns-id ===== + first := f.send(apnsVMKnownToken, auth) + if first.Status != 200 { + t.Fatalf("push known device -> %d: %v", first.Status, first.Body) + } + apnsID := first.Headers["apns-id"] + if !apnsVMUUID.MatchString(apnsID) { + t.Fatalf("apns-id = %q, want canonical 8-4-4-4-12 UUID", apnsID) + } + if len(first.Body) != 0 { + t.Fatalf("push body = %v, want empty body on 200", first.Body) + } + // A second push gets a different id. + second := f.send(apnsVMKnownToken, auth) + if second.Status != 200 { + t.Fatalf("second push -> %d: %v", second.Status, second.Body) + } + apnsID2 := second.Headers["apns-id"] + if apnsID2 == "" || apnsID2 == apnsID { + t.Fatalf("second apns-id = %q, want distinct from %q", apnsID2, apnsID) + } + + // ===== unknown device tokens are 400 baddevicetoken ===== + wantReason(t, f.send(apnsVMNewToken, auth), 400, "BadDeviceToken", "unknown device") + + // ===== empty aps payloads are 400 payloadempty ===== + // A nil body, a body without aps, and an aps with no alert/badge/sound. + if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken, nil, auth); r.Status != 400 || r.Body["reason"] != "PayloadEmpty" { + t.Fatalf("nil body -> %d %v, want 400 PayloadEmpty", r.Status, r.Body) + } + if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken, + map[string]any{"custom": map[string]any{"x": 1}}, auth); r.Status != 400 || r.Body["reason"] != "PayloadEmpty" { + t.Fatalf("no aps -> %d %v, want 400 PayloadEmpty", r.Status, r.Body) + } + if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken, + map[string]any{"aps": map[string]any{}}, auth); r.Status != 400 || r.Body["reason"] != "PayloadEmpty" { + t.Fatalf("empty aps -> %d %v, want 400 PayloadEmpty", r.Status, r.Body) + } + // A sound-only aps is a valid minimal payload. + if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken, + map[string]any{"aps": map[string]any{"sound": "chime.aiff"}}, auth); r.Status != 200 { + t.Fatalf("sound-only aps -> %d, want 200; body %v", r.Status, r.Body) + } + + // ===== sent notifications are retrievable per device ===== + list := f.call("on_get_notifications", "GET", "/3/device/"+apnsVMKnownToken+"/notifications", + apnsVMKnownToken, nil, auth) + if list.Status != 200 { + t.Fatalf("notifications -> %d: %v", list.Status, list.Body) + } + items := list.BodyList + if len(items) != 3 { + t.Fatalf("notifications count = %d, want 3 (two pushes + sound-only)", len(items)) + } + ids := map[string]bool{} + for _, it := range items { + n, _ := it.(map[string]any) + ids[n["apns-id"].(string)] = true + if _, has := n["aps"]; !has { + t.Fatalf("notification %v missing aps", n) + } + if _, has := n["sent_at"]; !has { + t.Fatalf("notification %v missing sent_at", n) + } + } + if !ids[apnsID] || !ids[apnsID2] { + t.Fatalf("notification ids %v missing the served apns-ids %q / %q", ids, apnsID, apnsID2) + } + // The unknown device has nothing stored. + if r := f.call("on_get_notifications", "GET", "/3/device/"+apnsVMNewToken+"/notifications", + apnsVMNewToken, nil, auth); r.Status != 200 || len(r.BodyList) != 0 { + t.Fatalf("unknown device notifications -> %d %v, want 200 []", r.Status, r.BodyList) + } + // The gate applies to the internal route too. + wantReason(t, f.call("on_get_notifications", "GET", "/3/device/"+apnsVMKnownToken+"/notifications", + apnsVMKnownToken, nil, ""), 403, "MissingProviderToken", "notifications without auth") +} diff --git a/adapters/avalara-style/README.md b/adapters/avalara-style/README.md index 1647abff..08b340d5 100644 --- a/adapters/avalara-style/README.md +++ b/adapters/avalara-style/README.md @@ -85,19 +85,19 @@ Each line's `details` array shows the per-jurisdiction breakdown (rate + tax). ```json // Tax calculation { - "totalTax": "9.5", - "totalTaxable": "100.0", + "totalTax": 9.5, + "totalTaxable": 100.0, "totalRate": 0.095, "lines": [{ "number": "1", - "tax": "9.5", + "tax": 9.5, "details": [ - { "jurisdiction": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": "4.75" }, - { "jurisdiction": "CA County", "jurisdictionType": "County", "rate": 0.0238, "tax": "2.38" }, + { "jurisdiction": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": 4.75 }, + { "jurisdiction": "CA County", "jurisdictionType": "County", "rate": 0.0238, "tax": 2.38 }, ... ] }], - "summary": [{ "jurisName": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": "4.75" }] + "summary": [{ "jurisName": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": 4.75 }] } // Error diff --git a/adapters/avalara-style/adapter.yaml b/adapters/avalara-style/adapter.yaml index 2dd3bc74..03866676 100644 --- a/adapters/avalara-style/adapter.yaml +++ b/adapters/avalara-style/adapter.yaml @@ -36,6 +36,7 @@ endpoints: - route: /v2/transactions/{id}/void method: POST handler: scripts/transactions.star#on_void_transaction + concurrency_key: id # read-then-cancel read-modify-write, serialized per transaction # --- Companies --- - route: /v2/companies diff --git a/adapters/avalara-style/scripts/lib.star b/adapters/avalara-style/scripts/lib.star index 1dcbef85..46f11e63 100644 --- a/adapters/avalara-style/scripts/lib.star +++ b/adapters/avalara-style/scripts/lib.star @@ -165,8 +165,8 @@ def _compute_tax(lines, state): result_lines.append({ "number": number, - "tax": _fmt(tax), - "taxCalculated": _fmt(tax), + "tax": tax, + "taxCalculated": tax, "taxCode": line_in.get("taxCode", "P0000000"), "details": details, }) @@ -180,19 +180,22 @@ def _compute_tax(lines, state): "jurisdictionType": bd["jurisdictionType"], "taxType": "Sales", "rate": bd["rate"], - "tax": _fmt(total_taxable * bd["rate"]), + "tax": total_taxable * bd["rate"], "taxName": bd["taxName"], }) + # AvaTax serializes decimal fields as JSON numbers, not strings. return { - "totalTax": _fmt(total_tax), - "totalTaxable": _fmt(total_taxable), + "totalTax": total_tax, + "totalTaxable": total_taxable, "totalRate": rate, "lines": result_lines, "summary": summary, } -# _to_float converts a value to float64 (handles int, string, float). +# _to_float converts a value to float64. AvaTax SDKs send decimal strings +# ("100.00") as often as JSON numbers, so the string form is parsed too +# (digits, one optional dot, one optional leading sign); junk becomes 0.0. def _to_float(val): if val == None: return 0.0 @@ -200,8 +203,22 @@ def _to_float(val): return float(val) if type(val) == "float": return val - # String. - return 0.0 + s = str(val) + if s == "": + return 0.0 + dots = 0 + for i in range(len(s)): + ch = s[i] + if ch == ".": + dots = dots + 1 + if dots > 1: + return 0.0 + elif ch == "+" or ch == "-": + if i != 0 or len(s) == 1: + return 0.0 + elif ch < "0" or ch > "9": + return 0.0 + return float(s) # _round2 rounds a float to 2 decimal places. def _round2(val): @@ -212,10 +229,6 @@ def _round2(val): def _round4(val): return float(int(val * 10000 + 0.5)) / 10000.0 -# _fmt formats a float as a string like "8.25". -def _fmt(val): - return str(val) - # --- Query + pagination helpers --- # _get_query safely returns a query parameter value. diff --git a/adapters/avalara-style/scripts/transactions.star b/adapters/avalara-style/scripts/transactions.star index ff6d1f2e..e2904d74 100644 --- a/adapters/avalara-style/scripts/transactions.star +++ b/adapters/avalara-style/scripts/transactions.star @@ -36,10 +36,10 @@ def on_create_transaction(req): state = _address_state(addresses) tax_result = _compute_tax(lines, state) - # Compute total amount = totalTaxable + totalTax. + # Compute total amount = totalTaxable + totalTax (decimals are numbers). total_taxable = tax_result["totalTaxable"] total_tax = tax_result["totalTax"] - total_amount = _fmt(_to_float(total_taxable) + _to_float(total_tax)) + total_amount = total_taxable + total_tax txn_id = _txn_id() code = _txn_code() @@ -103,8 +103,8 @@ def on_list_transactions(req): "id": doc.get("id", ""), "code": doc.get("code", ""), "type": doc.get("type", "SalesInvoice"), - "totalAmount": doc.get("totalAmount", "0"), - "totalTax": doc.get("totalTax", "0"), + "totalAmount": doc.get("totalAmount", 0), + "totalTax": doc.get("totalTax", 0), "status": doc.get("status", "Saved"), }) diff --git a/adapters/avalara_style_test.go b/adapters/avalara_style_test.go new file mode 100644 index 00000000..c8a35b59 --- /dev/null +++ b/adapters/avalara_style_test.go @@ -0,0 +1,552 @@ +package adapters + +import ( + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the avalara-style adapter scripts directly (lib.star preloaded) over +// a shared store and a virtual clock: the Bearer-or-Basic credential gate on +// every /v2 endpoint, the deterministic State/County/City/Special tax split +// with its SDK-decimal-string inputs, the transaction lifecycle (create, +// list with OData $filter/$orderBy/$top/$skip, read by id, void) and the +// companies/nexus/taxcode catalogs — with the undated-transaction default +// stamped from the clock instead of sleeps. +const ( + avHost = "sandbox-rest.avatax.test" + avBearer = "Bearer av-account-license-key" + avBasic = "Basic YXZheGE6bGljZW5zZQ==" // any credential opens the mock gate +) + +type avalaraFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newAvalaraFixture(t *testing.T, start time.Time) *avalaraFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "avalara-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &avalaraFixture{t: t, vc: vc, host: avHost, vms: map[string]*starlark.VM{ + "tax": load("tax.star"), "txns": load("transactions.star"), + "companies": load("companies.star"), "defs": load("definitions.star"), + }} +} + +func (f *avalaraFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// --- assertion helpers --- + +// avErr asserts the AvaTax error envelope — {error: {code, message, target, +// details: []}} — plus the HTTP status. +func avErr(t *testing.T, r starlark.Response, wantStatus int, wantCode string) { + t.Helper() + if r.Status != wantStatus { + t.Fatalf("%s error: status -> %d, want %d; body %v", wantCode, r.Status, wantStatus, r.Body) + } + e, ok := r.Body["error"].(map[string]any) + if !ok { + t.Fatalf("%s error: error = %v, want object", wantCode, r.Body["error"]) + } + if e["code"] != wantCode { + t.Fatalf("error code = %v, want %s (envelope %v)", e["code"], wantCode, e) + } + if m, _ := e["message"].(string); m == "" { + t.Fatalf("%s error: message is empty", wantCode) + } + if _, has := e["target"]; !has { + t.Fatalf("%s error: no target field: %v", wantCode, e) + } + if _, ok := e["details"].([]any); !ok { + t.Fatalf("%s error: details = %v, want an array", wantCode, e["details"]) + } +} + +// avNum compares a JSON number regardless of int64/float64 width (stored +// docs round-trip through the collection, where ints come back floats). +func avNum(t *testing.T, v any, want float64, what string) { + t.Helper() + switch n := v.(type) { + case int64: + if float64(n) != want { + t.Fatalf("%s = %d, want %v", what, n, want) + } + case float64: + if n != want { + t.Fatalf("%s = %v, want %v", what, n, want) + } + default: + t.Fatalf("%s = %T(%v), want number %v", what, v, v, want) + } +} + +// avClose compares a float with a 1e-9 tolerance (split rates are each the +// nearest float64 to their decimal, so their sum carries representation dust). +func avClose(t *testing.T, v any, want float64, what string) { + t.Helper() + n, ok := v.(float64) + if !ok { + t.Fatalf("%s = %T(%v), want float %v", what, v, v, want) + } + if math.Abs(n-want) > 1e-9 { + t.Fatalf("%s = %v, want %v (±1e-9)", what, n, want) + } +} + +// avValue pulls the "value" array out of an OData list envelope. +func avValue(t *testing.T, r starlark.Response) []any { + t.Helper() + if r.Status != 200 { + t.Fatalf("list -> %d: %v", r.Status, r.Body) + } + docs, ok := r.Body["value"].([]any) + if !ok { + t.Fatalf("value = %v, want array", r.Body["value"]) + } + return docs +} + +// TestAvalaraCredentialGate: AvaTax accepts a Bearer (account/license key) or +// HTTP Basic credential on every /v2 endpoint; anything less gets the 401 +// AuthenticationRequired envelope. +func TestAvalaraCredentialGate(t *testing.T) { + f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + + // ===== every v2 endpoint demands a credential: a bare call is a 401 AuthenticationRequired envelope ===== + for _, probe := range []struct{ group, handler, method, path string }{ + {"tax", "on_calculate_tax", "POST", "/v2/tax/calculate"}, + {"txns", "on_create_transaction", "POST", "/v2/transactions/create"}, + {"txns", "on_list_transactions", "GET", "/v2/transactions"}, + {"txns", "on_get_transaction", "GET", "/v2/transactions/3000000001"}, + {"txns", "on_void_transaction", "POST", "/v2/transactions/3000000001/void"}, + {"companies", "on_list_companies", "GET", "/v2/companies"}, + {"defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses"}, + {"defs", "on_list_taxcodes", "GET", "/v2/definitions/taxcodes"}, + } { + avErr(t, f.call(probe.group, probe.handler, probe.method, probe.path, + map[string]string{"id": "3000000001"}, nil, map[string]any{}, ""), 401, "AuthenticationRequired") + } + + // ===== any Bearer or any HTTP Basic credential opens the gate ===== + // As-is: credentials are presence-checked only — the mock accepts any + // bearer token or basic pair (real AvaTax validates account/license keys). + for _, auth := range []string{avBearer, "Bearer anything", avBasic} { + r := f.call("tax", "on_calculate_tax", "POST", "/v2/tax/calculate", nil, nil, + map[string]any{"lines": []any{map[string]any{"amount": 100}}}, auth) + if r.Status != 200 { + t.Fatalf("calculate with %q -> %d: %v", auth, r.Status, r.Body) + } + } + + // ===== a non-Basic/Non-Bearer scheme does not count as a credential ===== + avErr(t, f.call("companies", "on_list_companies", "GET", "/v2/companies", nil, nil, nil, "Token abc123"), + 401, "AuthenticationRequired") +} + +// TestAvalaraTaxCalculateJurisdictionSplit: the quick estimate — state-keyed +// effective rate, the deterministic State/County/City/Special split, per-line +// cents rounding, and the SDK's decimal-string amounts. +func TestAvalaraTaxCalculateJurisdictionSplit(t *testing.T) { + f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + calc := func(body map[string]any) starlark.Response { + return f.call("tax", "on_calculate_tax", "POST", "/v2/tax/calculate", nil, nil, body, avBearer) + } + caAddr := map[string]any{"singleLocation": map[string]any{ + "line1": "100 Main St", "city": "San Francisco", "region": "CA", "country": "US", "postalCode": "94016", + }} + + // ===== the effective rate keys off the address state (CA 0.095) with a State/County/City/Special breakdown ===== + r := calc(map[string]any{ + "addresses": caAddr, + "lines": []any{map[string]any{"number": "1", "quantity": 1, "amount": 100.0, "taxCode": "P0000000"}}, + }) + if r.Status != 200 { + t.Fatalf("calculate -> %d: %v", r.Status, r.Body) + } + avNum(t, r.Body["totalRate"], 0.095, "CA totalRate") + avNum(t, r.Body["totalTaxable"], 100, "CA totalTaxable") + avNum(t, r.Body["totalTax"], 9.5, "CA totalTax") + lines, ok := r.Body["lines"].([]any) + if !ok || len(lines) != 1 { + t.Fatalf("lines = %v, want the one submitted line", r.Body["lines"]) + } + line := lines[0].(map[string]any) + if line["number"] != "1" || line["taxCode"] != "P0000000" { + t.Fatalf("line echo = %v", line) + } + avNum(t, line["tax"], 9.5, "line tax") + avNum(t, line["taxCalculated"], 9.5, "line taxCalculated") + details, ok := line["details"].([]any) + if !ok || len(details) != 4 { + t.Fatalf("details = %v, want the four jurisdictions", line["details"]) + } + wantSplit := []struct { + juris, jtype string + rate, tax float64 + }{ + {"CA", "State", 0.0475, 4.75}, + {"CA County", "County", 0.0238, 2.38}, + {"CA City", "City", 0.019, 1.9}, + {"Special", "Special", 0.0047, 0.47}, + } + sum := 0.0 + for i, w := range wantSplit { + d := details[i].(map[string]any) + if d["jurisdiction"] != w.juris || d["jurisdictionType"] != w.jtype { + t.Fatalf("details[%d] = %v, want %s/%s", i, d, w.juris, w.jtype) + } + avNum(t, d["rate"], w.rate, w.jtype+" rate") + avNum(t, d["tax"], w.tax, w.jtype+" tax") + sum += d["rate"].(float64) + } + avClose(t, sum, 0.095, "sum of jurisdiction rates") + + // ===== the summary aggregates the taxable base per jurisdiction ===== + summary, ok := r.Body["summary"].([]any) + if !ok || len(summary) != 4 { + t.Fatalf("summary = %v, want the four jurisdictions", r.Body["summary"]) + } + s0 := summary[0].(map[string]any) + if s0["jurisName"] != "CA" || s0["jurisCode"] != "CA" || s0["taxType"] != "Sales" { + t.Fatalf("summary[0] = %v", s0) + } + avNum(t, s0["rate"], 0.0475, "summary state rate") + avNum(t, s0["tax"], 4.75, "summary state tax") + + // ===== per-line tax rounds to cents: two lines aggregate, line 2 keeps its own tax ===== + r = calc(map[string]any{ + "addresses": caAddr, + "lines": []any{ + map[string]any{"number": "1", "amount": 100.0}, + map[string]any{"number": "2", "amount": 50.0}, + }, + }) + avNum(t, r.Body["totalTaxable"], 150, "two-line totalTaxable") + avNum(t, r.Body["totalTax"], 14.25, "two-line totalTax") + lines = r.Body["lines"].([]any) + avNum(t, lines[1].(map[string]any)["tax"], 4.75, "line 2 tax") + + // ===== SDK decimal strings ("100.00") price identically to JSON numbers ===== + // AvaTax SDKs serialize decimals as strings as often as numbers; both + // feed the same engine. + r = calc(map[string]any{ + "addresses": caAddr, + "lines": []any{map[string]any{"number": "1", "amount": "100.00"}}, + }) + avNum(t, r.Body["totalTaxable"], 100, "string-amount totalTaxable") + avNum(t, r.Body["totalTax"], 9.5, "string-amount totalTax") + + // ===== the shipFrom/shipTo form keys off shipTo (NY 0.0875) ===== + r = calc(map[string]any{ + "addresses": map[string]any{ + "shipFrom": map[string]any{"line1": "1 Main St", "city": "Seattle", "region": "WA", "country": "US"}, + "shipTo": map[string]any{"line1": "9 Broadway", "city": "New York", "region": "NY", "country": "US"}, + }, + "lines": []any{map[string]any{"number": "1", "amount": "200.00"}}, + }) + avNum(t, r.Body["totalRate"], 0.0875, "shipTo NY totalRate") + avNum(t, r.Body["totalTax"], 17.5, "shipTo NY totalTax") + + // ===== unknown or missing addresses fall back to the synthetic 0.0825 default ===== + // As-is: real AvaTax geocodes and rejects unresolvable addresses; the + // mock applies a flat default rate. + r = calc(map[string]any{"lines": []any{map[string]any{"amount": 100}}}) + avNum(t, r.Body["totalRate"], 0.0825, "no-address totalRate") + avNum(t, r.Body["totalTax"], 8.25, "no-address totalTax") + r = calc(map[string]any{ + "addresses": map[string]any{"singleLocation": map[string]any{"region": "ZZ"}}, + "lines": []any{map[string]any{"amount": 100}}, + }) + avNum(t, r.Body["totalRate"], 0.0825, "unknown-state totalRate") +} + +// TestAvalaraTransactionLifecycle: create with AvaTax defaults, the +// OData-filterable list with $top/$skip paging, read by id, and void. +func TestAvalaraTransactionLifecycle(t *testing.T) { + f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + caAddr := map[string]any{"singleLocation": map[string]any{ + "line1": "100 Main St", "city": "San Francisco", "region": "CA", "country": "US", + }} + create := func(body map[string]any) starlark.Response { + return f.call("txns", "on_create_transaction", "POST", "/v2/transactions/create", nil, nil, body, avBearer) + } + get := func(id string) starlark.Response { + return f.call("txns", "on_get_transaction", "GET", "/v2/transactions/"+id, map[string]string{"id": id}, nil, nil, avBearer) + } + list := func(query map[string]string) starlark.Response { + return f.call("txns", "on_list_transactions", "GET", "/v2/transactions", nil, query, nil, avBearer) + } + + // ===== create prices the document, mints id/code/companyId and applies AvaTax defaults ===== + created := create(map[string]any{ + "companyCode": "DEFAULT", "date": "2026-06-15", + "addresses": caAddr, + "lines": []any{ + map[string]any{"number": "1", "quantity": 1, "amount": 100.0, "taxCode": "P0000000"}, + map[string]any{"number": "2", "quantity": 2, "amount": "50.00", "taxCode": "P0000000"}, + }, + }) + if created.Status != 200 { + t.Fatalf("create transaction -> %d: %v", created.Status, created.Body) + } + txnID, _ := created.Body["id"].(string) + if !strings.HasPrefix(txnID, "3000000") { + t.Fatalf("transaction id = %q, want an AvaTax-style 3000000... id", txnID) + } + if code, _ := created.Body["code"].(string); !strings.HasPrefix(code, "INV-") { + t.Fatalf("transaction code = %v, want INV- prefix", created.Body["code"]) + } + if cid, _ := created.Body["companyId"].(string); !strings.HasPrefix(cid, "2000000") { + t.Fatalf("companyId = %v, want a 2000000... company id", created.Body["companyId"]) + } + if created.Body["type"] != "SalesInvoice" || created.Body["status"] != "Saved" || created.Body["customerCode"] != "CUST001" { + t.Fatalf("create defaults = %v", created.Body) + } + avNum(t, created.Body["totalTaxable"], 150, "created totalTaxable") + avNum(t, created.Body["totalTax"], 14.25, "created totalTax") + avNum(t, created.Body["totalAmount"], 164.25, "created totalAmount (taxable + tax)") + if addr, _ := created.Body["addresses"].(map[string]any); addr["singleLocation"] == nil { + t.Fatalf("created addresses = %v, want the singleLocation echo", created.Body["addresses"]) + } + if lines, ok := created.Body["lines"].([]any); !ok || len(lines) != 2 { + t.Fatalf("created lines = %v, want the two submitted lines", created.Body["lines"]) + } + + // ===== an omitted date defaults to the clock's today, and advances with it ===== + undated := create(map[string]any{ + "addresses": caAddr, + "lines": []any{map[string]any{"amount": 10}}, + }) + if undated.Body["date"] != "2026-02-03" { + t.Fatalf("undated transaction date = %v, want the clock's today", undated.Body["date"]) + } + f.vc.Advance(26 * time.Hour) + next := create(map[string]any{ + "addresses": caAddr, + "lines": []any{map[string]any{"amount": 10}}, + }) + if next.Body["date"] != "2026-02-04" { + t.Fatalf("post-advance date = %v, want the advanced clock's day", next.Body["date"]) + } + if created.Body["date"] != "2026-06-15" { + t.Fatalf("explicit date = %v, want it kept verbatim", created.Body["date"]) + } + + // ===== read round-trips by id; unknown ids are 404 NotFound ===== + byID := get(txnID) + if byID.Status != 200 { + t.Fatalf("get transaction -> %d: %v", byID.Status, byID.Body) + } + if byID.Body["id"] != txnID || byID.Body["status"] != "Saved" { + t.Fatalf("get transaction echo = %v", byID.Body) + } + avNum(t, byID.Body["totalAmount"], 164.25, "read-back totalAmount") + avErr(t, get("3000000999"), 404, "NotFound") + + // ===== the list supports OData $filter and $orderBy with @recordsetCount ===== + ret := create(map[string]any{ + "type": "ReturnInvoice", "date": "2026-06-16", + "addresses": map[string]any{"shipTo": map[string]any{"region": "NY", "country": "US"}}, + "lines": []any{map[string]any{"amount": "200.00"}}, + }) + retID, _ := ret.Body["id"].(string) + all := avValue(t, list(nil)) + if len(all) != 4 { + t.Fatalf("unfiltered list -> %d rows, want the 4 created", len(all)) + } + avNum(t, all[0].(map[string]any)["totalAmount"], 164.25, "list row 0 totalAmount") + filtered := avValue(t, list(map[string]string{"$filter": "type eq 'ReturnInvoice'"})) + if len(filtered) != 1 || filtered[0].(map[string]any)["id"] != retID { + t.Fatalf("$filter type -> %v, want the ReturnInvoice row", filtered) + } + avNum(t, filtered[0].(map[string]any)["totalTax"], 17.5, "ReturnInvoice totalTax") + sorted := avValue(t, list(map[string]string{"$orderBy": "totalTax desc"})) + if got := sorted[0].(map[string]any)["id"]; got != retID { + t.Fatalf("$orderBy totalTax desc = %v first, want the NY return (17.5 > 14.25)", got) + } + + // ===== $top/$skip pages through an @odata.nextLink that round-trips ===== + page1 := list(map[string]string{"$top": "1"}) + rows := avValue(t, page1) + avNum(t, page1.Body["@recordsetCount"], 4, "paged @recordsetCount") + if len(rows) != 1 || rows[0].(map[string]any)["id"] != txnID { + t.Fatalf("$top 1 first page = %v", rows) + } + if link, _ := page1.Body["@odata.nextLink"].(string); link != "/v2/transactions?$top=1&$skip=1" { + t.Fatalf("@odata.nextLink = %v, want /v2/transactions?$top=1&$skip=1", page1.Body["@odata.nextLink"]) + } + page2 := list(map[string]string{"$top": "1", "$skip": "1"}) + rows = avValue(t, page2) + if len(rows) != 1 || rows[0].(map[string]any)["id"] == txnID { + t.Fatalf("$skip 1 second page = %v, want the next row", rows) + } + if link, _ := page2.Body["@odata.nextLink"].(string); link != "/v2/transactions?$top=1&$skip=2" { + t.Fatalf("second page @odata.nextLink = %v, want $skip=2", page2.Body["@odata.nextLink"]) + } + last := list(map[string]string{"$top": "1", "$skip": "3"}) + rows = avValue(t, last) + if len(rows) != 1 { + t.Fatalf("final page = %v rows, want the fourth row", rows) + } + if _, has := last.Body["@odata.nextLink"]; has { + t.Fatalf("exhausted page still carries @odata.nextLink: %v", last.Body["@odata.nextLink"]) + } + avErr(t, list(map[string]string{"$top": "1", "$skip": "abc"}), 400, "InvalidCursor") + + // ===== void flips status to Cancelled and the record reads back cancelled ===== + void := f.call("txns", "on_void_transaction", "POST", "/v2/transactions/"+txnID+"/void", + map[string]string{"id": txnID}, nil, map[string]any{}, avBearer) + if void.Status != 200 { + t.Fatalf("void transaction -> %d: %v", void.Status, void.Body) + } + // As-is: the void response is a minimal {id, status} envelope; real + // AvaTax returns the full TransactionModel. + if void.Body["id"] != txnID || void.Body["status"] != "Cancelled" { + t.Fatalf("void response = %v, want {id, status Cancelled}", void.Body) + } + if after := get(txnID); after.Status != 200 || after.Body["status"] != "Cancelled" { + t.Fatalf("get after void -> %d %v, want the record kept with status Cancelled", after.Status, after.Body) + } + cancelled := avValue(t, list(map[string]string{"$filter": "status eq 'Cancelled'"})) + if len(cancelled) != 1 || cancelled[0].(map[string]any)["id"] != txnID { + t.Fatalf("$filter status Cancelled -> %v, want the voided row", cancelled) + } + avErr(t, f.call("txns", "on_void_transaction", "POST", "/v2/transactions/3000000999/void", + map[string]string{"id": "3000000999"}, nil, map[string]any{}, avBearer), 404, "NotFound") + + // ===== re-void is idempotent ===== + // As-is: real AvaTax rejects voiding an already-Cancelled document; the + // mock re-cancels with 200. + again := f.call("txns", "on_void_transaction", "POST", "/v2/transactions/"+txnID+"/void", + map[string]string{"id": txnID}, nil, map[string]any{}, avBearer) + if again.Status != 200 || again.Body["status"] != "Cancelled" { + t.Fatalf("re-void -> %d %v, want 200 Cancelled (as-is)", again.Status, again.Body) + } +} + +// TestAvalaraCatalogs: the companies, nexus and tax-code definition catalogs +// with their typed OData $filter literals. +func TestAvalaraCatalogs(t *testing.T) { + f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + + // ===== the companies catalog lists DEFAULT and STORE1 with default locations ===== + companies := f.call("companies", "on_list_companies", "GET", "/v2/companies", nil, nil, nil, avBearer) + rows := avValue(t, companies) + if len(rows) != 2 { + t.Fatalf("companies -> %d rows, want 2", len(rows)) + } + c0 := rows[0].(map[string]any) + c1 := rows[1].(map[string]any) + if c0["companyCode"] != "DEFAULT" || c0["name"] != "Default Company" { + t.Fatalf("company 0 = %v", c0) + } + if c1["companyCode"] != "STORE1" { + t.Fatalf("company 1 = %v", c1) + } + loc, _ := c1["defaultLocation"].(map[string]any) + if loc["region"] != "NY" || loc["city"] != "New York" { + t.Fatalf("STORE1 defaultLocation = %v", loc) + } + if _, ok := c0["id"].(string); !ok { + t.Fatalf("company id = %v, want a string id", c0["id"]) + } + + // ===== nexus $filter literals are typed: id eq 1001 matches ints, hasNexus eq true matches bools ===== + nexuses := f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, nil, nil, avBearer) + rows = avValue(t, nexuses) + if len(rows) != 3 { + t.Fatalf("nexuses -> %d rows, want 3", len(rows)) + } + n0 := rows[0].(map[string]any) + avNum(t, n0["id"], 1001, "nexus id") + if n0["jurisdictionCode"] != "CA" || n0["jurisdictionName"] != "California" || n0["hasNexus"] != true { + t.Fatalf("nexus 0 = %v", n0) + } + byID := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, + map[string]string{"$filter": "id eq 1001"}, nil, avBearer)) + if len(byID) != 1 || byID[0].(map[string]any)["jurisdictionCode"] != "CA" { + t.Fatalf("$filter id eq 1001 -> %v, want the CA nexus", byID) + } + if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, + map[string]string{"$filter": "id eq '1001'"}, nil, avBearer)); len(got) != 0 { + t.Fatalf("$filter id eq '1001' -> %v, want no rows (quoted literals compare as strings)", got) + } + if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, + map[string]string{"$filter": "hasNexus eq true"}, nil, avBearer)); len(got) != 3 { + t.Fatalf("$filter hasNexus eq true -> %d rows, want all 3", len(got)) + } + if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, + map[string]string{"$filter": "hasNexus eq false"}, nil, avBearer)); len(got) != 0 { + t.Fatalf("$filter hasNexus eq false -> %d rows, want none", len(got)) + } + if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, + map[string]string{"$filter": "jurisdictionCode eq 'NY'"}, nil, avBearer)); len(got) != 1 { + t.Fatalf("$filter jurisdictionCode eq 'NY' -> %d rows, want the NY nexus", len(got)) + } + + // ===== the taxcode catalog is filterable by taxCode ===== + codes := f.call("defs", "on_list_taxcodes", "GET", "/v2/definitions/taxcodes", nil, nil, nil, avBearer) + rows = avValue(t, codes) + if len(rows) != 4 { + t.Fatalf("taxcodes -> %d rows, want 4", len(rows)) + } + nt := avValue(t, f.call("defs", "on_list_taxcodes", "GET", "/v2/definitions/taxcodes", nil, + map[string]string{"$filter": "taxCode eq 'NT'"}, nil, avBearer)) + if len(nt) != 1 || nt[0].(map[string]any)["description"] != "Non-Taxable" { + t.Fatalf("$filter taxCode eq 'NT' -> %v", nt) + } +} diff --git a/adapters/cloudkit-style/scripts/lib.star b/adapters/cloudkit-style/scripts/lib.star index 9538c615..d1af539e 100644 --- a/adapters/cloudkit-style/scripts/lib.star +++ b/adapters/cloudkit-style/scripts/lib.star @@ -263,10 +263,15 @@ def _err(status, code, reason): "reason": reason, }) -# _to_int parses a decimal string to int. +# _to_int parses a decimal string to int. JSON numbers (resultsLimit) reach +# handlers as int or float, so those pass through untouched. def _to_int(s): if s == None or s == "": return 0 + if type(s) == "int": + return s + if type(s) == "float": + return int(s) n = 0 for i in range(len(s)): ch = s[i] diff --git a/adapters/cloudkit-style/scripts/records.star b/adapters/cloudkit-style/scripts/records.star index 8ccfea72..c831bc3d 100644 --- a/adapters/cloudkit-style/scripts/records.star +++ b/adapters/cloudkit-style/scripts/records.star @@ -181,7 +181,8 @@ def _do_update(rc, record): def _do_delete(rc, name): for doc in rc.list(): if doc.get("recordName") == name: - rc.delete(doc) + # delete takes the stored id string, not the doc (like update). + rc.delete(doc.get("id", "")) return # _matches_filters is retained for the legacy EQUALS-only shape; the query diff --git a/adapters/cloudkit_style_test.go b/adapters/cloudkit_style_test.go new file mode 100644 index 00000000..9d3e3031 --- /dev/null +++ b/adapters/cloudkit_style_test.go @@ -0,0 +1,467 @@ +package adapters + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the cloudkit-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: the server-to-server request +// signature gate (real ECDSA P-256 over date:raw_body:path), and the five +// public-database routes — users/current, zones/list, records/lookup, +// records/query and records/modify — against the seeded zones and Notes. +const ckVMKeyID = "stunt-cloudkit-s2s-key-1" + +// ckVMPrivPEM mirrors the adapter's documented synthetic server-to-server +// keypair (README): the private half signs here exactly the way a real +// CloudKit web-services client would. +const ckVMPrivPEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYWuBd8XWfDZ/VcJu +QB09aJCel9cxSAjTK0x6bsCiCVGhRANCAAQ6HcT9YUUVXeqvZzOGGORZ89rQX0Ne +n8el83/HqrrAlhhMFWpHo3iuSuqqFdhgd9XBSPPM9+E2RK/+qy+C4Qiw +-----END PRIVATE KEY-----` + +// ckVMPrefix is the public-database path every route hangs off (the +// container/env path params are opaque to the handlers). +const ckVMPrefix = "/database/1/iCloud.stunt.test/production/public" + +type ckVMFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newCloudKitVMFixture(t *testing.T, start time.Time) *ckVMFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "cloudkit-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &ckVMFixture{t: t, vc: vc, host: "api.apple-cloudkit.test", vms: map[string]*starlark.VM{ + "records": load("records.star"), "zones": load("zones.star"), "users": load("users.star"), + }} +} + +// signed computes the three X-Apple-CloudKit-Request-* headers over +// date:rawBody:path for the marshaled body (ECDSA P-256 + SHA-256, base64 +// raw r||s) — the client side of the adapter's documented scheme. +func (f *ckVMFixture) signed(path string, body map[string]any, date time.Time) map[string]string { + f.t.Helper() + raw, err := json.Marshal(body) + if err != nil { + f.t.Fatalf("marshal sign body: %v", err) + } + block, _ := pem.Decode([]byte(ckVMPrivPEM)) + if block == nil { + f.t.Fatal("bad test private key PEM") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + f.t.Fatalf("parse test key: %v", err) + } + priv, ok := key.(*ecdsa.PrivateKey) + if !ok { + f.t.Fatal("test key is not ECDSA") + } + dateStr := date.UTC().Format(time.RFC3339) + msg := dateStr + ":" + string(raw) + ":" + path + h := sha256.Sum256([]byte(msg)) + r, s, err := ecdsa.Sign(rand.Reader, priv, h[:]) + if err != nil { + f.t.Fatalf("ecdsa sign: %v", err) + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return map[string]string{ + "X-Apple-CloudKit-Request-KeyID": ckVMKeyID, + "X-Apple-CloudKit-Request-ISO8601Date": dateStr, + "X-Apple-CloudKit-Request-SignatureBase64": base64.StdEncoding.EncodeToString(sig), + } +} + +// call drives handler on the named script VM with a JSON body: the raw bytes +// feed raw_body (the signature covers them verbatim) and the re-parsed map +// feeds body — what the engine hands a handler. hdrs nil = correctly signed +// for this body at the virtual clock's now. +func (f *ckVMFixture) call(group, handler, method, path string, body map[string]any, hdrs map[string]string) starlark.Response { + f.t.Helper() + if body == nil { + body = map[string]any{} + } + raw, err := json.Marshal(body) + if err != nil { + f.t.Fatalf("marshal body: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(raw, &parsed); err != nil { + f.t.Fatalf("re-parse body: %v", err) + } + if hdrs == nil { + hdrs = f.signed(path, body, f.vc.Now()) + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: hdrs, Body: parsed, RawBody: string(raw), + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// ckVMNum coerces a JSON number that may surface as int64 or float64 (the +// backing store round-trips records as JSON) to float64 for comparison. +func ckVMNum(v any) float64 { + switch n := v.(type) { + case int64: + return float64(n) + case float64: + return n + default: + return -1 + } +} + +// wantAuthFailed asserts the CloudKit auth-failure envelope. +func wantAuthFailed(t *testing.T, r starlark.Response, label string) { + t.Helper() + if r.Status != 401 { + t.Fatalf("%s -> %d, want 401; body %v", label, r.Status, r.Body) + } + if r.Body["serverErrorCode"] != "AUTHENTICATION_FAILED" { + t.Fatalf("%s serverErrorCode = %v, want AUTHENTICATION_FAILED", label, r.Body["serverErrorCode"]) + } +} + +func TestCloudKitVMRequestSignatureGate(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newCloudKitVMFixture(t, base) + lookup := ckVMPrefix + "/records/lookup" + body := map[string]any{"records": []any{map[string]any{"recordName": "note-001"}}} + + // ===== the s2s signature gate rejects unsigned tampered stale and foreign-key requests ===== + // Unsigned (no X-Apple-CloudKit-Request-* headers at all) -> 401. + wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, map[string]string{}), "unsigned") + + // Unknown KeyID (signed correctly, but not the registered key id). + h := f.signed(lookup, body, f.vc.Now()) + h["X-Apple-CloudKit-Request-KeyID"] = "someone-elses-key" + wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, h), "unknown key id") + + // Tampered body: the signature covers note-001's raw bytes, note-002 ships. + h = f.signed(lookup, body, f.vc.Now()) + other := map[string]any{"records": []any{map[string]any{"recordName": "note-002"}}} + wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, other, h), "tampered body") + + // Garbage signature (valid base64, wrong bytes). + h = f.signed(lookup, body, f.vc.Now()) + junk := make([]byte, 64) + for i := range junk { + junk[i] = byte(i) + } + h["X-Apple-CloudKit-Request-SignatureBase64"] = base64.StdEncoding.EncodeToString(junk) + wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, h), "garbage signature") + + // Stale date: 20 minutes off the (virtual) server clock is outside the + // 10-minute window. + wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, + f.signed(lookup, body, f.vc.Now().Add(-20*time.Minute))), "stale date") + + // Inside the window (9 minutes old) still passes. + if r := f.call("records", "on_lookup", "GET", lookup, body, + f.signed(lookup, body, f.vc.Now().Add(-9*time.Minute))); r.Status != 200 { + t.Fatalf("9-minute-old date -> %d, want 200; body %v", r.Status, r.Body) + } +} + +func TestCloudKitVMDatabaseRoutes(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newCloudKitVMFixture(t, base) + + // ===== users current returns the s2s owner identity ===== + user := f.call("users", "on_current_user", "GET", ckVMPrefix+"/users/current", nil, nil) + if user.Status != 200 { + t.Fatalf("users/current -> %d: %v", user.Status, user.Body) + } + if user.Body["userRecordName"] != "_owner" { + t.Fatalf("userRecordName = %v, want _owner", user.Body["userRecordName"]) + } + if user.Body["firstName"] != "Test" || user.Body["lastName"] != "User" { + t.Fatalf("user identity = %v %v, want Test User", user.Body["firstName"], user.Body["lastName"]) + } + + // ===== zones list seeds defaults filters by prefix and pages ===== + zones := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list", nil, nil) + if zones.Status != 200 { + t.Fatalf("zones/list -> %d: %v", zones.Status, zones.Body) + } + zoneList, _ := zones.Body["zones"].([]any) + if len(zoneList) != 2 { + t.Fatalf("seeded zones = %d, want 2 (%v)", len(zoneList), zones.Body) + } + first, _ := zoneList[0].(map[string]any) + if first["zoneName"] != "_default" || first["zoneType"] != "DEFAULT_ZONE" { + t.Fatalf("first zone = %v, want _default DEFAULT_ZONE", first) + } + // zoneNamePrefix filters before paging. + prefixed := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list", + map[string]any{"zoneNamePrefix": "_own"}, nil) + pl, _ := prefixed.Body["zones"].([]any) + if len(pl) != 1 || pl[0].(map[string]any)["zoneName"] != "_owner" { + t.Fatalf("prefix _own zones = %v, want only _owner", prefixed.Body) + } + // resultsLimit pages through the seeded zones with a continuation marker + // (JSON number, the shape real CloudKit documents). + page1 := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list", + map[string]any{"resultsLimit": float64(1)}, nil) + if page1.Status != 200 { + t.Fatalf("zones page 1 -> %d: %v", page1.Status, page1.Body) + } + p1, _ := page1.Body["zones"].([]any) + if len(p1) != 1 || p1[0].(map[string]any)["zoneName"] != "_default" { + t.Fatalf("zones page 1 = %v, want just _default", page1.Body) + } + marker, has := page1.Body["continuationMarker"] + if !has || marker == "" { + t.Fatalf("zones page 1 continuationMarker = %v, want non-empty", page1.Body["continuationMarker"]) + } + page2 := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list", + map[string]any{"resultsLimit": float64(1), "continuationMarker": marker}, nil) + p2, _ := page2.Body["zones"].([]any) + if len(p2) != 1 || p2[0].(map[string]any)["zoneName"] != "_owner" { + t.Fatalf("zones page 2 = %v, want just _owner", page2.Body) + } + if _, has := page2.Body["continuationMarker"]; has { + t.Fatalf("zones page 2 continuationMarker = %v, want absent (exhausted)", page2.Body["continuationMarker"]) + } + + // ===== records lookup returns the seeded shape with inline NOT_FOUND ===== + look := f.call("records", "on_lookup", "GET", ckVMPrefix+"/records/lookup", + map[string]any{"records": []any{ + map[string]any{"recordName": "note-001"}, + map[string]any{"recordName": "no-such-record"}, + }}, nil) + if look.Status != 200 { + t.Fatalf("records/lookup -> %d: %v", look.Status, look.Body) + } + recs, _ := look.Body["records"].([]any) + if len(recs) != 2 { + t.Fatalf("lookup records = %d, want 2", len(recs)) + } + rec, _ := recs[0].(map[string]any) + if rec["recordName"] != "note-001" || rec["recordType"] != "Notes" { + t.Fatalf("lookup record = %v, want note-001 Notes", rec) + } + fields, _ := rec["fields"].(map[string]any) + title, _ := fields["title"].(map[string]any) + if title["value"] != "Welcome Note" { + t.Fatalf("note-001 title = %v, want Welcome Note", title["value"]) + } + created, _ := rec["created"].(map[string]any) + if ts := ckVMNum(created["timestamp"]); ts != 1700000000000 { + t.Fatalf("note-001 created.timestamp = %v, want 1700000000000", created["timestamp"]) + } + // A missing name yields an inline per-record error, not a failed request. + missing, _ := recs[1].(map[string]any) + if missing["serverErrorCode"] != "NOT_FOUND" { + t.Fatalf("missing record entry = %v, want inline NOT_FOUND", missing) + } + + // ===== records query filters sorts and pages on a numeric resultsLimit ===== + eq := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{ + "query": map[string]any{ + "recordType": "Notes", + "filterBy": []any{map[string]any{"fieldName": "title", "comparator": "EQUALS", "fieldValue": map[string]any{"value": "Welcome Note"}}}, + }, + }, nil) + if eq.Status != 200 { + t.Fatalf("query EQUALS -> %d: %v", eq.Status, eq.Body) + } + if eqRecs, _ := eq.Body["records"].([]any); len(eqRecs) != 1 { + t.Fatalf("query EQUALS records = %d, want 1 (%v)", len(eqRecs), eq.Body) + } + bw := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{ + "query": map[string]any{ + "recordType": "Notes", + "filterBy": []any{map[string]any{"fieldName": "title", "comparator": "BEGINS_WITH", "fieldValue": map[string]any{"value": "Shopping"}}}, + }, + }, nil) + if bwRecs, _ := bw.Body["records"].([]any); len(bwRecs) != 1 || + bwRecs[0].(map[string]any)["recordName"] != "note-002" { + t.Fatalf("query BEGINS_WITH = %v, want note-002 only", bw.Body) + } + // sortBy descending on title puts "Welcome Note" before "Shopping List". + sorted := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{ + "query": map[string]any{ + "recordType": "Notes", + "sortBy": []any{map[string]any{"fieldName": "title", "ascending": false}}, + }, + }, nil) + sRecs, _ := sorted.Body["records"].([]any) + if len(sRecs) != 2 { + t.Fatalf("query sorted records = %d, want 2", len(sRecs)) + } + s0 := sRecs[0].(map[string]any)["fields"].(map[string]any)["title"].(map[string]any)["value"] + s1 := sRecs[1].(map[string]any)["fields"].(map[string]any)["title"].(map[string]any)["value"] + if s0 != "Welcome Note" || s1 != "Shopping List" { + t.Fatalf("sortBy title desc = [%v %v], want [Welcome Note Shopping List]", s0, s1) + } + // Page through both notes one at a time following continuationMarker. + pages, marker := 0, "" + for { + qb := map[string]any{"query": map[string]any{"recordType": "Notes"}, "resultsLimit": float64(1)} + if marker != "" { + qb["continuationMarker"] = marker + } + page := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", qb, nil) + if page.Status != 200 { + t.Fatalf("query page %d -> %d: %v", pages+1, page.Status, page.Body) + } + pRecs, _ := page.Body["records"].([]any) + if len(pRecs) != 1 { + t.Fatalf("query page %d records = %d, want 1", pages+1, len(pRecs)) + } + pages++ + m, has := page.Body["continuationMarker"] + if !has { + break + } + marker, _ = m.(string) + } + if pages != 2 { + t.Fatalf("query pages = %d, want 2", pages) + } + // A syntactically invalid marker is the adapter's 400, not a 500. + if r := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{ + "query": map[string]any{"recordType": "Notes"}, "continuationMarker": "bogus", + }, nil); r.Status != 400 { + t.Fatalf("invalid continuationMarker -> %d, want 400; body %v", r.Status, r.Body) + } + + // ===== records modify creates updates and deletes round-trip ===== + mod := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{ + "operations": []any{map[string]any{ + "operationType": "create", + "record": map[string]any{ + "recordName": "note-003", "recordType": "Notes", + "fields": map[string]any{ + "title": map[string]any{"value": "New Note"}, + "body": map[string]any{"value": "Created via modify"}, + }, + }, + }}, + }, nil) + if mod.Status != 200 { + t.Fatalf("modify create -> %d: %v", mod.Status, mod.Body) + } + mRecs, _ := mod.Body["records"].([]any) + if len(mRecs) != 1 || mRecs[0].(map[string]any)["recordName"] != "note-003" { + t.Fatalf("modify create records = %v, want note-003", mod.Body) + } + // The created record reads back through lookup (stateful). + back := f.call("records", "on_lookup", "GET", ckVMPrefix+"/records/lookup", + map[string]any{"records": []any{map[string]any{"recordName": "note-003"}}}, nil) + bRecs, _ := back.Body["records"].([]any) + if len(bRecs) != 1 { + t.Fatalf("lookup note-003 = %v", back.Body) + } + if got := bRecs[0].(map[string]any)["fields"].(map[string]any)["title"].(map[string]any)["value"]; got != "New Note" { + t.Fatalf("note-003 title = %v, want New Note", got) + } + // Update merges fields (body untouched) and bumps modified, not created. + upd := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{ + "operations": []any{map[string]any{ + "operationType": "update", + "record": map[string]any{ + "recordName": "note-003", + "fields": map[string]any{"title": map[string]any{"value": "Renamed"}}, + }, + }}, + }, nil) + uRec, _ := upd.Body["records"].([]any)[0].(map[string]any) + uFields := uRec["fields"].(map[string]any) + if uFields["title"].(map[string]any)["value"] != "Renamed" { + t.Fatalf("updated title = %v, want Renamed", uFields["title"]) + } + if uFields["body"].(map[string]any)["value"] != "Created via modify" { + t.Fatalf("updated body = %v, want merged original", uFields["body"]) + } + if ckVMNum(uRec["modified"].(map[string]any)["timestamp"]) <= ckVMNum(uRec["created"].(map[string]any)["timestamp"]) { + t.Fatalf("modified %v <= created %v", uRec["modified"], uRec["created"]) + } + // forceUpdate of a missing record is an inline NOT_FOUND entry. + miss := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{ + "operations": []any{map[string]any{ + "operationType": "forceUpdate", + "record": map[string]any{"recordName": "ghost", "fields": map[string]any{}}, + }}, + }, nil) + if e := miss.Body["records"].([]any)[0].(map[string]any)["serverErrorCode"]; e != "NOT_FOUND" { + t.Fatalf("forceUpdate ghost = %v, want inline NOT_FOUND", e) + } + // Unknown operation types are reported inline, not fatal. + bad := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{ + "operations": []any{map[string]any{"operationType": "frobnicate"}}, + }, nil) + if e := bad.Body["records"].([]any)[0].(map[string]any)["serverErrorCode"]; e != "BAD_REQUEST" { + t.Fatalf("unknown operation = %v, want inline BAD_REQUEST", e) + } + // Delete removes the record and reports {recordName, deleted}. + del := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{ + "operations": []any{map[string]any{ + "operationType": "delete", "record": map[string]any{"recordName": "note-003"}, + }}, + }, nil) + dRec, _ := del.Body["records"].([]any)[0].(map[string]any) + if dRec["deleted"] != true || dRec["recordName"] != "note-003" { + t.Fatalf("delete record = %v, want {note-003, deleted}", dRec) + } + gone := f.call("records", "on_lookup", "GET", ckVMPrefix+"/records/lookup", + map[string]any{"records": []any{map[string]any{"recordName": "note-003"}}}, nil) + if e := gone.Body["records"].([]any)[0].(map[string]any)["serverErrorCode"]; e != "NOT_FOUND" { + t.Fatalf("lookup deleted note-003 = %v, want NOT_FOUND", e) + } +} diff --git a/adapters/dropbox-style/README.md b/adapters/dropbox-style/README.md index d7625624..dda69b79 100644 --- a/adapters/dropbox-style/README.md +++ b/adapters/dropbox-style/README.md @@ -29,7 +29,7 @@ create in one request is visible in subsequent requests within the same | Method | Route | Handler | Description | |--------|-------|---------|-------------| -| POST | `/2/files/upload` | `files.star#on_upload` | Upload a file (JSON `{path, content}`) | +| POST | `/2/files/upload` | `files.star#on_upload` | Upload a file (JSON `{path, content}`; an existing path → `409 path/conflict`, like the real default `mode: add`) | | POST | `/2/files/download` | `files.star#on_download` | Download file content (`{path}` or `{id}`) | | POST | `/2/files/list_folder` | `files.star#on_list_folder` | List entries under a path prefix (missing path → `409 path/not_found`; file path → `409 path/not_folder`) | | POST | `/2/files/get_metadata` | `files.star#on_get_metadata` | Get entry metadata (`{path}`) | diff --git a/adapters/dropbox-style/scripts/files.star b/adapters/dropbox-style/scripts/files.star index 965818cb..6411259c 100644 --- a/adapters/dropbox-style/scripts/files.star +++ b/adapters/dropbox-style/scripts/files.star @@ -80,13 +80,35 @@ def on_upload(req): if body == None: body = {} - # path: JSON body wins, else the Dropbox-API-Arg header. + # path and write mode: JSON body wins, else the Dropbox-API-Arg header. + arg = _api_arg(req) path = body.get("path", "") if path == None or path == "": - path = _api_arg(req).get("path", "") + path = arg.get("path", "") if path == None or path == "": return respond(409, _error("path")) + mode = body.get("mode", arg.get("mode", "")) + if type(mode) == "dict": + mode = mode.get(".tag", "add") + if mode == None or mode == "": + mode = "add" + autorename = body.get("autorename", arg.get("autorename", False)) + if autorename == None: + autorename = False + + # Real upload defaults to mode "add": an existing path is a 409 conflict. + # Inserting anyway would fork the path (two rows at one path_display) and + # strand the older row ahead of every later path read — a stale download. + existing = _find_by_path(path) + if existing != None: + if mode == "overwrite" or mode == "update": + pass # replace in place below + elif autorename: + path = _renamed_path(path) + else: + return respond(409, _error("path/conflict")) + # content: JSON body wins, else the raw request body (real octet-stream). content = body.get("content", None) if content == None: @@ -95,6 +117,10 @@ def on_upload(req): content = "" file_id = _next_id() + if existing != None and (mode == "overwrite" or mode == "update"): + # Replace in place: keep the existing id so revisions and the blob + # stay anchored to the same file. + file_id = existing.get("id", file_id) b = store_blob("dropbox") b.put(file_id, content) @@ -121,9 +147,30 @@ def on_upload(req): "content_hash": _content_hash(content), } c = store_collection("entries") - c.insert(doc) + if existing != None and (mode == "overwrite" or mode == "update"): + c.update(file_id, doc) + else: + c.insert(doc) return respond(200, doc) +# _renamed_path suffixes " (1)", " (2)"... before the extension until the +# path is free — the autorename contract. +def _renamed_path(path): + c = store_collection("entries") + stem = path + ext = "" + dot = path.rfind(".") + slash = path.rfind("/") + if dot > slash: + stem = path[:dot] + ext = path[dot:] + n = 1 + while True: + candidate = stem + " (" + str(n) + ")" + ext + if _find_by_path(candidate) == None: + return candidate + n = n + 1 + # POST /2/files/download — download file content. # # Body: {path} or {id}. Returns the raw file content with Content-Type @@ -203,7 +250,9 @@ def on_list_folder(req): page, next_cursor = _list_page(req, entries) if page == None: - return respond(400, {"error_summary": "invalid_cursor", "error": {".tag": "invalid_cursor"}}) + # Real invalid_cursor carries the same summary+tag envelope as the + # 409s (list_folder/continue answers exactly this shape on a bad cursor). + return respond(400, _error("invalid_cursor")) return respond(200, { "entries": page, "cursor": next_cursor if next_cursor != None else "", diff --git a/adapters/dropbox_style_test.go b/adapters/dropbox_style_test.go new file mode 100644 index 00000000..d440eea7 --- /dev/null +++ b/adapters/dropbox_style_test.go @@ -0,0 +1,732 @@ +package adapters + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the dropbox-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock. The API is RPC-style: all eight +// routes are POST /2/{files|users}/{action} with the arguments in the JSON +// body (no path params, no query strings), and every handler here is called +// exactly that way. Covered: both upload request shapes (the documented +// JSON {path, content} convenience form and the real Dropbox-API-Arg + +// raw-body form), the Dropbox content_hash scheme under the virtual clock, +// the path_prefix subtree listing with body-cursor paging, raw octet-stream +// downloads by path and id, the 409 path/* and 401 access-token error +// envelopes, and the permanent cascading folder delete audited through the +// internal trash tombstones. + +const ( + dropboxHost = "api.dropbox.test" + dropboxToken = "sl.test_token_mock" // README's seeded static mock token +) + +type dropboxFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + store *primitives.Store + kv *kv.KV + host string +} + +func newDropboxFixture(t *testing.T, start time.Time) *dropboxFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "dropbox-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, err := primitives.Open(filepath.Join(tmp, "s.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + kvStore, err := kv.Open(filepath.Join(tmp, "s.kv.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { kvStore.Close() }) + blobStore, err := blob.Open(filepath.Join(tmp, "blobs")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + + // Seed the entries collection like the engine does on boot (the one + // synthetic seed folder the adapter ships). + entries, err := store.Collection("entries") + if err != nil { + t.Fatalf("entries collection: %v", err) + } + if err := entries.Seed(filepath.Join(root, "fixtures", "entries.jsonl")); err != nil { + t.Fatalf("seed entries: %v", err) + } + if _, err := store.Collection("trash"); err != nil { + t.Fatalf("trash collection: %v", err) + } + + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &dropboxFixture{ + t: t, vc: vc, store: store, kv: kvStore, host: dropboxHost, + vms: map[string]*starlark.VM{"files": load("files.star"), "users": load("users.star")}, + } +} + +// call invokes one of the eight POST /2/... RPC handlers. Every argument an +// RPC handler reads lives in the body (round-tripped through JSON so numbers +// arrive as floats, the wire shape); query is only ever ignored. auth is the +// exact Authorization header value ("" omits the header). +func (f *dropboxFixture) call(group, handler, route string, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + var wire map[string]any + if body != nil { + wire = wireBody(f.t, body) + } + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: "POST", Path: route, Host: f.host, Headers: headers, + Body: wire, Params: map[string]string{}, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, route, err) + } + return resp +} + +// uploadRaw drives on_upload in the real request shape: the file arguments +// in the Dropbox-API-Arg header (a JSON string) and the raw file bytes as +// the octet-stream request body. apiArg is used verbatim, so malformed +// values exercise the never-raise path. +func (f *dropboxFixture) uploadRaw(apiArg, raw, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{ + "Content-Type": "application/octet-stream", + "Dropbox-API-Arg": apiArg, + } + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms["files"].Call("on_upload", starlark.Request{ + Method: "POST", Path: "/2/files/upload", Host: f.host, Headers: headers, + Params: map[string]string{}, RawBody: raw, + }) + if err != nil { + f.t.Fatalf("on_upload raw: %v", err) + } + return resp +} + +// dropboxNum coerces a response number to float64: freshly built docs carry +// int64 while collection round-trips (get_metadata, listings) come back as +// JSON floats. +func dropboxNum(t *testing.T, v any) float64 { + t.Helper() + switch n := v.(type) { + case int64: + return float64(n) + case float64: + return n + default: + t.Fatalf("value %v (%T) is not a number", v, v) + return 0 + } +} + +// dropboxPaths returns the path_lower of every entry in a list_folder +// response, in server order. +func dropboxPaths(t *testing.T, r starlark.Response) []string { + t.Helper() + entries, ok := r.Body["entries"].([]any) + if !ok { + t.Fatalf("entries = %v (%T), want list", r.Body["entries"], r.Body["entries"]) + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + p, _ := e.(map[string]any)["path_lower"].(string) + out = append(out, p) + } + return out +} + +// dropboxWantErr asserts the simplified Dropbox error envelope: an +// error_summary of "/.." and a nested error .tag of exactly "". +func dropboxWantErr(t *testing.T, r starlark.Response, status int, tag string) { + t.Helper() + if r.Status != status { + t.Fatalf("status = %d, want %d (body %v)", r.Status, status, r.Body) + } + if r.Body["error_summary"] != tag+"/.." { + t.Fatalf("error_summary = %v, want %q", r.Body["error_summary"], tag+"/..") + } + errObj, ok := r.Body["error"].(map[string]any) + if !ok || errObj[".tag"] != tag { + t.Fatalf("error = %v, want nested {\".tag\": %q}", r.Body["error"], tag) + } +} + +// dropboxStyleContentHash mirrors the adapter's content hash: Dropbox's own +// scheme of SHA-256 over the concatenated per-4MiB-block SHA-256 digests. +func dropboxStyleContentHash(b []byte) string { + const block = 4 << 20 + h := sha256.New() + for i := 0; i == 0 || i < len(b); i += block { + end := i + block + if end > len(b) { + end = len(b) + } + d := sha256.Sum256(b[i:end]) + h.Write(d[:]) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// TestDropboxUploadForms: the documented JSON {path, content} convenience +// deviation, the real Dropbox-API-Arg + raw-body upload form, and the +// mode:"add" conflict an existing path now answers with. +func TestDropboxUploadForms(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDropboxFixture(t, base) + + // ===== upload takes the JSON {path, content} convenience body (documented deviation) ===== + // Real /2/files/upload posts raw bytes with the arguments in the + // Dropbox-API-Arg header; the mock also accepts this JSON form (see + // conformance/matrix.yaml) — asserted here as-is. + content := "dropbox-style vm suite notes" + up := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/VM Suite/notes.txt", + "content": content, + "client_modified": "2020-02-03T04:05:06Z", + }, "") + if up.Status != 200 { + t.Fatalf("JSON-form upload -> %d: %v", up.Status, up.Body) + } + if up.Body[".tag"] != "file" || up.Body["id"] != "id_1" { + t.Fatalf("upload doc = %v, want .tag file / id_1", up.Body) + } + if up.Body["name"] != "notes.txt" || up.Body["path_display"] != "/VM Suite/notes.txt" || + up.Body["path_lower"] != "/vm suite/notes.txt" { + t.Fatalf("upload paths = %v", up.Body) + } + if got := dropboxNum(t, up.Body["size"]); got != float64(len(content)) { + t.Fatalf("size = %v, want %d", up.Body["size"], len(content)) + } + if up.Body["client_modified"] != "2020-02-03T04:05:06Z" { + t.Fatalf("client_modified = %v, want the client-declared value echoed", up.Body["client_modified"]) + } + if up.Body["server_modified"] != base.Format(time.RFC3339) { + t.Fatalf("server_modified = %v, want the live (virtual) clock %s", + up.Body["server_modified"], base.Format(time.RFC3339)) + } + if up.Body["content_hash"] != dropboxStyleContentHash([]byte(content)) { + t.Fatalf("content_hash = %v, want the Go-computed Dropbox-scheme hash %s", + up.Body["content_hash"], dropboxStyleContentHash([]byte(content))) + } + + // ===== the real RPC upload (Dropbox-API-Arg header + raw octet-stream body) lands identically ===== + raw := "raw-binary-payload" + argUp := f.uploadRaw(`{"path": "/VM Suite/raw.bin", "client_modified": "2021-06-07T08:09:10Z"}`, raw, "") + if argUp.Status != 200 { + t.Fatalf("RPC-form upload -> %d: %v", argUp.Status, argUp.Body) + } + if argUp.Body["id"] != "id_2" || argUp.Body["name"] != "raw.bin" { + t.Fatalf("RPC-form upload doc = %v, want id_2 / raw.bin", argUp.Body) + } + if got := dropboxNum(t, argUp.Body["size"]); got != float64(len(raw)) { + t.Fatalf("RPC-form size = %v, want %d", argUp.Body["size"], len(raw)) + } + if argUp.Body["content_hash"] != dropboxStyleContentHash([]byte(raw)) { + t.Fatalf("RPC-form content_hash = %v, want %s", argUp.Body["content_hash"], dropboxStyleContentHash([]byte(raw))) + } + // The stored bytes download back verbatim under the octet-stream type. + dl := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/VM Suite/raw.bin"}, "") + if dl.Status != 200 || dl.RawBody != raw { + t.Fatalf("download after RPC-form upload -> %d %q, want the raw bytes", dl.Status, dl.RawBody) + } + if ct := dl.Headers["Content-Type"]; ct != "application/octet-stream" { + t.Fatalf("download Content-Type = %q, want application/octet-stream", ct) + } + // A malformed Dropbox-API-Arg is untrusted header input: it must never + // raise, and with no path anywhere the flat path error answers. + badArg := f.uploadRaw("{not-json", "ignored-bytes", "") + dropboxWantErr(t, badArg, 409, "path") + + // ===== re-uploading an existing path answers the real mode:"add" conflict ===== + // Real upload defaults to mode "add" + autorename:false: an existing + // path is a conflict. The mock used to insert a second row at the same + // path, leaving the older row ahead of every later path read. + dup := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/VM Suite/notes.txt", "content": "different bytes entirely", + }, "") + dropboxWantErr(t, dup, 409, "path/conflict") + // Dropbox paths are case-insensitive (path_lower is the canonical key), + // so a differently-cased path is the same conflict. + ci := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/VM SUITE/NOTES.TXT", "content": "x", + }, "") + dropboxWantErr(t, ci, 409, "path/conflict") + // The original entry is untouched: same id, and exactly one notes row. + meta := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/VM Suite/notes.txt"}, "") + if meta.Status != 200 || meta.Body["id"] != "id_1" { + t.Fatalf("metadata after conflicting upload -> %d %v, want the original id_1", meta.Status, meta.Body) + } + root := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": ""}, "") + notes := 0 + for _, p := range dropboxPaths(t, root) { + if p == "/vm suite/notes.txt" { + notes++ + } + } + if notes != 1 || len(dropboxPaths(t, root)) != 3 { // seed folder + notes + raw.bin + t.Fatalf("root after conflicting upload = %v, want exactly one notes row of 3", dropboxPaths(t, root)) + } + + // ===== mode overwrite replaces in place; autorename forks a suffixed path ===== + // The default conflict is mode-specific: SDK clients re-upload via + // WriteMode.overwrite, and autorename is the escape hatch for adds. + over := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/VM Suite/notes.txt", "content": "replaced", "mode": map[string]any{".tag": "overwrite"}, + }, "") + if over.Status != 200 || over.Body["id"] != "id_1" { + t.Fatalf("overwrite upload -> %d %v, want 200 replacing id_1 in place", over.Status, over.Body) + } + if got := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/VM Suite/notes.txt"}, ""); got.Status != 200 || got.RawBody != "replaced" { + t.Fatalf("download after overwrite -> %d %q, want the replaced bytes", got.Status, got.RawBody) + } + auto := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/VM Suite/notes.txt", "content": "forked", "autorename": true, + }, "") + if auto.Status != 200 || auto.Body["path_display"] != "/VM Suite/notes (1).txt" { + t.Fatalf("autorename upload -> %d %v, want the (1)-suffixed path", auto.Status, auto.Body) + } +} + +// TestDropboxListFolderSubtreeAndPaging: the whole-subtree listing deviation, +// the root/missing/file-path listing errors, and body-cursor paging applied +// after the path filter. +func TestDropboxListFolderSubtreeAndPaging(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDropboxFixture(t, base) + + must200 := func(handler, route string, body map[string]any) starlark.Response { + t.Helper() + r := f.call("files", handler, route, nil, body, "") + if r.Status != 200 { + t.Fatalf("%s %v -> %d: %v", handler, body, r.Status, r.Body) + } + return r + } + must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects"}) // id_1 + must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects/Archive"}) // id_2 + must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/plan.txt", "content": "cascade plan"}) + must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/Archive/2019.txt", "content": "old stuff"}) + must200("on_upload", "/2/files/upload", map[string]any{"path": "/root.txt", "content": "root level"}) + + // ===== list_folder returns the whole path-prefix subtree, not one level ===== + // Real list_folder returns only the direct children; the mock returns + // the entire subtree at once (documented deviation) — including the + // folder itself and depth-2 entries. + sub := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/Projects"}) + got := dropboxPaths(t, sub) + want := []string{"/projects", "/projects/archive", "/projects/plan.txt", "/projects/archive/2019.txt"} + if len(got) != len(want) { + t.Fatalf("subtree = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("subtree[%d] = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } + if sub.Body["has_more"] != false || sub.Body["cursor"] != "" { + t.Fatalf("unpaged envelope = %v %v, want has_more false / empty cursor", sub.Body["has_more"], sub.Body["cursor"]) + } + // The path lookup is case-insensitive, like path_lower itself. + subCI := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/projects"}) + if p := dropboxPaths(t, subCI); len(p) != 4 || p[3] != "/projects/archive/2019.txt" { + t.Fatalf("case-insensitive subtree = %v, want the same 4 entries", p) + } + + // ===== the root listing spans everything; unknown and file paths carry distinct 409 tags ===== + root := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": ""}) + if p := dropboxPaths(t, root); len(p) != 6 { // seed + 5 created + t.Fatalf("root listing = %v, want 6 entries", p) + } + slash := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/"}) + if p := dropboxPaths(t, slash); len(p) != 6 { + t.Fatalf("\"/\" listing = %v, want the same 6 entries as \"\"", p) + } + missing := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": "/nope"}, "") + dropboxWantErr(t, missing, 409, "path/not_found") + fileArg := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": "/root.txt"}, "") + dropboxWantErr(t, fileArg, 409, "path/not_folder") + + // ===== paging slices the filtered subtree by body cursor, ignoring query strings ===== + // The /2/ RPC style reads its arguments from the JSON body: a REST-style + // ?limit=1 query is not even looked at. + qOnly := f.call("files", "on_list_folder", "/2/files/list_folder", + map[string]string{"limit": "1"}, map[string]any{"path": "/Projects"}, "") + if p := dropboxPaths(t, qOnly); len(p) != 4 { + t.Fatalf("query-string limit was honored (%v) — paging must read the body", p) + } + p1 := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/Projects", "limit": 2}) + if p := dropboxPaths(t, p1); len(p) != 2 || p[0] != "/projects" || p[1] != "/projects/archive" { + t.Fatalf("page 1 = %v, want the first two subtree entries", p) + } + if p1.Body["has_more"] != true || p1.Body["cursor"] != "2" { + t.Fatalf("page 1 envelope = %v %v, want has_more true / cursor \"2\"", p1.Body["has_more"], p1.Body["cursor"]) + } + p2 := must200("on_list_folder", "/2/files/list_folder", + map[string]any{"path": "/Projects", "limit": 2, "cursor": "2"}) + if p := dropboxPaths(t, p2); len(p) != 2 || p[0] != "/projects/plan.txt" || p[1] != "/projects/archive/2019.txt" { + t.Fatalf("page 2 = %v, want the last two subtree entries", p) + } + if p2.Body["has_more"] != false || p2.Body["cursor"] != "" { + t.Fatalf("final page envelope = %v %v, want has_more false / empty cursor", p2.Body["has_more"], p2.Body["cursor"]) + } + // The two pages cover the subtree exactly once. + seen := map[string]int{} + for _, p := range append(dropboxPaths(t, p1), dropboxPaths(t, p2)...) { + seen[p]++ + } + if len(seen) != 4 { + t.Fatalf("paged union = %v, want the 4 distinct subtree paths", seen) + } + for _, n := range seen { + if n != 1 { + t.Fatalf("paged union repeats a path: %v", seen) + } + } + // A garbage cursor gets the real 400 invalid_cursor envelope (same + // summary+tag shape as the 409s). + badCursor := f.call("files", "on_list_folder", "/2/files/list_folder", nil, + map[string]any{"path": "/Projects", "limit": 2, "cursor": "abc"}, "") + dropboxWantErr(t, badCursor, 400, "invalid_cursor") +} + +// TestDropboxReadsAndErrorEnvelopes: octet-stream downloads by path and id, +// the 409 path envelope across declined reads, and the synthetic temp link. +func TestDropboxReadsAndErrorEnvelopes(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDropboxFixture(t, base) + + content := "quarterly numbers" + up := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/reports/q3.txt", "content": content, + }, "") + if up.Status != 200 { + t.Fatalf("upload -> %d: %v", up.Status, up.Body) + } + fileID, _ := up.Body["id"].(string) + + // ===== download streams raw bytes by path and by id, with metadata alongside ===== + byPath := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/reports/q3.txt"}, "") + if byPath.Status != 200 || byPath.RawBody != content { + t.Fatalf("download by path -> %d %q, want the raw content", byPath.Status, byPath.RawBody) + } + if ct := byPath.Headers["Content-Type"]; ct != "application/octet-stream" { + t.Fatalf("download Content-Type = %q, want application/octet-stream (no JSON envelope)", ct) + } + byID := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"id": fileID}, "") + if byID.Status != 200 || byID.RawBody != content { + t.Fatalf("download by id -> %d %q, want the same raw content", byID.Status, byID.RawBody) + } + // Metadata persists through the collection with the upload's fields. + meta := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/reports/q3.txt"}, "") + if meta.Status != 200 || meta.Body["id"] != fileID || meta.Body["name"] != "q3.txt" { + t.Fatalf("get_metadata -> %d %v, want the persisted file doc", meta.Status, meta.Body) + } + if got := dropboxNum(t, meta.Body["size"]); got != float64(len(content)) { + t.Fatalf("persisted size = %v, want %d", meta.Body["size"], len(content)) + } + if meta.Body["content_hash"] != dropboxStyleContentHash([]byte(content)) { + t.Fatalf("persisted content_hash = %v", meta.Body["content_hash"]) + } + + // ===== folders and unknown paths decline under the 409 path envelope ===== + folder := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/Dossier"}, "") + if folder.Status != 200 || folder.Body[".tag"] != "folder" { + t.Fatalf("create folder -> %d %v", folder.Status, folder.Body) + } + dlFolder := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/Dossier"}, "") + dropboxWantErr(t, dlFolder, 409, "path/disallowed") + dlMissing := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/gone.txt"}, "") + dropboxWantErr(t, dlMissing, 409, "path/not_found") + metaMissing := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/gone.txt"}, "") + dropboxWantErr(t, metaMissing, 409, "path/not_found") + // A request with no path at all gets the bare "path" tag, not a crash. + noPath := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{}, "") + dropboxWantErr(t, noPath, 409, "path") + noFolderPath := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{}, "") + dropboxWantErr(t, noFolderPath, 409, "path") + delMissing := f.call("files", "on_delete", "/2/files/delete", nil, map[string]any{"path": "/gone.txt"}, "") + dropboxWantErr(t, delMissing, 409, "path/not_found") + + // ===== get_temporary_link pairs the file's metadata with the synthetic link ===== + link := f.call("files", "on_get_temporary_link", "/2/files/get_temporary_link", nil, map[string]any{"path": "/reports/q3.txt"}, "") + if link.Status != 200 { + t.Fatalf("get_temporary_link -> %d: %v", link.Status, link.Body) + } + // The URL is synthetic and does not serve the content (documented + // deviation) — asserted verbatim. + if link.Body["link"] != "https://dl.dropboxusercontent.com/synthetic-temporary-link" { + t.Fatalf("link = %v, want the synthetic URL", link.Body["link"]) + } + if md, ok := link.Body["metadata"].(map[string]any); !ok || md["id"] != fileID { + t.Fatalf("link metadata = %v, want the full file doc for %s", link.Body["metadata"], fileID) + } + linkFolder := f.call("files", "on_get_temporary_link", "/2/files/get_temporary_link", nil, map[string]any{"path": "/Dossier"}, "") + dropboxWantErr(t, linkFolder, 409, "path/disallowed") + linkMissing := f.call("files", "on_get_temporary_link", "/2/files/get_temporary_link", nil, map[string]any{"path": "/gone.txt"}, "") + dropboxWantErr(t, linkMissing, 409, "path/not_found") +} + +// TestDropboxAuthGate: the bearer validation the adapter applies only when a +// token is presented, and the documented no-header openness. +func TestDropboxAuthGate(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDropboxFixture(t, base) + + // ===== a presented bearer must be registered: unknown and expired tokens get distinct 401 tags ===== + // An unknown token is invalid on every surface, in Dropbox's envelope. + unknown := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/x.txt", "content": "x", + }, "Bearer sl.not_a_real_token") + dropboxWantErr(t, unknown, 401, "invalid_access_token") + unknownAcct := f.call("users", "on_get_current_account", "/2/users/get_current_account", nil, + map[string]any{}, "Bearer sl.not_a_real_token") + dropboxWantErr(t, unknownAcct, 401, "invalid_access_token") + // A registered but past-expiry token flips to the expired tag. Tokens + // live in the KV store as token_ -> unix-seconds expiry. + if err := f.kv.Set("dropbox", "token_sl.stale_token", "1000"); err != nil { + t.Fatalf("seed stale token: %v", err) + } + expired := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/x.txt", "content": "x", + }, "Bearer sl.stale_token") + dropboxWantErr(t, expired, 401, "expired_access_token") + // The README's static mock token is seeded on first use and passes. + static := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/authed.txt", "content": "authed", + }, "Bearer "+dropboxToken) + if static.Status != 200 { + t.Fatalf("static mock token -> %d: %v", static.Status, static.Body) + } + + // ===== an absent Authorization header stays open (documented deviation) ===== + // Real Dropbox requires auth on every route; the mock accepts requests + // with no Authorization header at all (documented deviation, kept for + // the shared engine test helpers). + open := f.call("users", "on_get_current_account", "/2/users/get_current_account", nil, map[string]any{}, "") + if open.Status != 200 || open.Body["account_id"] == nil { + t.Fatalf("no-header account -> %d %v, want 200", open.Status, open.Body) + } + openUp := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/open.txt", "content": "no auth header", + }, "") + if openUp.Status != 200 { + t.Fatalf("no-header upload -> %d: %v", openUp.Status, openUp.Body) + } +} + +// TestDropboxFolderAndAccount: folder creation with the case-insensitive +// conflict, and the synthetic /2/users account snapshot. +func TestDropboxFolderAndAccount(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDropboxFixture(t, base) + + // ===== create_folder mints folder metadata and conflicts case-insensitively ===== + cf := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/Reports"}, "") + if cf.Status != 200 { + t.Fatalf("create_folder -> %d: %v", cf.Status, cf.Body) + } + if cf.Body[".tag"] != "folder" || cf.Body["id"] != "id_1" || cf.Body["name"] != "Reports" { + t.Fatalf("folder doc = %v, want .tag folder / id_1 / Reports", cf.Body) + } + if cf.Body["path_lower"] != "/reports" || cf.Body["path_display"] != "/Reports" { + t.Fatalf("folder paths = %v", cf.Body) + } + if cf.Body["server_modified"] != base.Format(time.RFC3339) { + t.Fatalf("folder server_modified = %v, want the clock's %s", + cf.Body["server_modified"], base.Format(time.RFC3339)) + } + // Folders carry no file fields. + for _, k := range []string{"size", "content_hash", "client_modified"} { + if _, has := cf.Body[k]; has { + t.Fatalf("folder doc carries file field %q: %v", k, cf.Body) + } + } + dup := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/Reports"}, "") + dropboxWantErr(t, dup, 409, "path/conflict") + // The case-insensitive namespace: /REPORTS is the same folder. + ci := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/REPORTS"}, "") + dropboxWantErr(t, ci, 409, "path/conflict") + + // ===== get_current_account returns the synthetic /2/users snapshot ===== + acct := f.call("users", "on_get_current_account", "/2/users/get_current_account", nil, map[string]any{}, "") + if acct.Status != 200 { + t.Fatalf("get_current_account -> %d: %v", acct.Status, acct.Body) + } + if acct.Body["account_id"] != "dbid:synthetic-local-test-account" || + acct.Body["email"] != "test-user@example.local" || + acct.Body["email_verified"] != true || acct.Body["country"] != "US" || acct.Body["locale"] != "en" { + t.Fatalf("account = %v, want the synthetic snapshot", acct.Body) + } + name, ok := acct.Body["name"].(map[string]any) + if !ok || name["given_name"] != "Local" || name["surname"] != "Test User" || + name["display_name"] != "Local Test User" || name["abbreviated_name"] != "LT" { + t.Fatalf("account name = %v, want all five name fields", acct.Body["name"]) + } +} + +// TestDropboxCascadeDeleteAndTrash: the permanent, cascading folder delete — +// visible removal from every read path, the trash tombstone audit trail, and +// the no-restore freshness of re-created paths. +func TestDropboxCascadeDeleteAndTrash(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDropboxFixture(t, base) + + must200 := func(handler, route string, body map[string]any) map[string]any { + t.Helper() + r := f.call("files", handler, route, nil, body, "") + if r.Status != 200 { + t.Fatalf("%s %v -> %d: %v", handler, body, r.Status, r.Body) + } + return r.Body + } + proj := must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects"}) // id_1 + archive := must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects/Archive"}) // id_2 + plan := must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/plan.txt", "content": "cascade plan"}) + old := must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/Archive/2019.txt", "content": "old stuff"}) + keep := must200("on_upload", "/2/files/upload", map[string]any{"path": "/keep-me.txt", "content": "survivor"}) + + // The delete lands at a known later clock time so the tombstone stamps + // are assertable. + f.vc.Advance(2 * time.Hour) + delAt := base.Add(2 * time.Hour).Format(time.RFC3339) + + // ===== deleting a folder removes its entire subtree from every read path ===== + del := f.call("files", "on_delete", "/2/files/delete", nil, map[string]any{"path": "/Projects"}, "") + if del.Status != 200 || del.Body["id"] != proj["id"] || del.Body[".tag"] != "folder" { + t.Fatalf("delete -> %d %v, want 200 echoing the folder doc", del.Status, del.Body) + } + for _, path := range []string{ + "/Projects", "/Projects/Archive", "/Projects/plan.txt", "/Projects/Archive/2019.txt", + } { + r := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": path}, "") + dropboxWantErr(t, r, 409, "path/not_found") + } + for _, id := range []any{plan["id"], old["id"]} { + r := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"id": id.(string)}, "") + dropboxWantErr(t, r, 409, "path/not_found") + } + // The deleted folder itself cannot be listed anymore. + listGone := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": "/Projects"}, "") + dropboxWantErr(t, listGone, 409, "path/not_found") + // The root keeps the seed folder and the sibling — nothing else leaks. + root := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": ""}, "") + if p := dropboxPaths(t, root); len(p) != 2 || p[0] != "/seed folder" || p[1] != "/keep-me.txt" { + t.Fatalf("root after cascade = %v, want only the seed folder and /keep-me.txt", p) + } + _ = archive + + // ===== trash tombstones audit the exact cascade batch ===== + // Every removed row lands in the internal trash collection exactly once, + // stamped with the delete time and the batch root that took it out. + trashCol, err := f.store.Collection("trash") + if err != nil { + t.Fatalf("trash collection: %v", err) + } + tombs, err := trashCol.List() + if err != nil { + t.Fatalf("trash list: %v", err) + } + if len(tombs) != 4 { + t.Fatalf("trash has %d tombstones, want the 4 cascaded entries", len(tombs)) + } + wantIDs := map[string]bool{ + proj["id"].(string): true, archive["id"].(string): true, + plan["id"].(string): true, old["id"].(string): true, + } + for _, tomb := range tombs { + id, _ := tomb["id"].(string) + if !wantIDs[id] { + t.Fatalf("tombstone for unexpected id %q (want %v)", id, wantIDs) + } + delete(wantIDs, id) + if tomb["_deleted_at"] != delAt { + t.Fatalf("tombstone %s _deleted_at = %v, want %s", id, tomb["_deleted_at"], delAt) + } + if tomb["_batch_root"] != proj["id"] { + t.Fatalf("tombstone %s _batch_root = %v, want the deleted root %v", + id, tomb["_batch_root"], proj["id"]) + } + } + if len(wantIDs) != 0 { + t.Fatalf("cascade missed entries: %v", wantIDs) + } + // The entries collection itself holds only the seed folder and the + // survivor — no orphans left under the deleted parent. + entriesCol, err := f.store.Collection("entries") + if err != nil { + t.Fatalf("entries collection: %v", err) + } + n, err := entriesCol.Count() + if err != nil { + t.Fatalf("entries count: %v", err) + } + if n != 2 { + t.Fatalf("entries count after cascade = %d, want 2 (seed + /keep-me.txt)", n) + } + _ = keep + + // ===== delete is permanent: a re-created path is a brand-new entry ===== + fresh := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{ + "path": "/Projects/plan.txt", "content": "new plan", + }, "") + if fresh.Status != 200 { + t.Fatalf("re-upload at deleted path -> %d: %v", fresh.Status, fresh.Body) + } + if fresh.Body["id"] == plan["id"] || fresh.Body["content_hash"] == plan["content_hash"] { + t.Fatalf("re-upload reused the deleted entry: %v", fresh.Body) + } + // A single-file delete still works standalone, and stays deleted. + single := f.call("files", "on_delete", "/2/files/delete", nil, map[string]any{"path": "/keep-me.txt"}, "") + if single.Status != 200 || single.Body["id"] != keep["id"] { + t.Fatalf("single delete -> %d %v", single.Status, single.Body) + } + after := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/keep-me.txt"}, "") + dropboxWantErr(t, after, 409, "path/not_found") +} diff --git a/adapters/dune-style/scripts/lib.star b/adapters/dune-style/scripts/lib.star index 74f145ff..22af19a1 100644 --- a/adapters/dune-style/scripts/lib.star +++ b/adapters/dune-style/scripts/lib.star @@ -283,14 +283,17 @@ def _csv_field(v): # _next_uri mints the absolute continuation URL Dune returns when more rows # remain beyond the requested page (offset carries the position, limit the # page size). The host is the simulator's own request host so clients can -# follow the URL directly. +# follow the URL directly; behind a TLS proxy the forwarded proto is +# honored so the absolute URL stays followable. def _next_uri(req, exec_id, offset, limit): host = req.get("host", "") if host == None: host = "" scheme = req.get("headers", {}).get("x-forwarded-proto", "") if scheme == None or scheme == "": - scheme = "http" if host.startswith("127.0.0.1") or host.startswith("localhost") else "http" + # direct simulator traffic is plain http — never guess https, the + # local listener does not speak it + scheme = "http" return (scheme + "://" + host + "/api/v1/execution/" + exec_id + "/results?offset=" + str(offset) + "&limit=" + str(limit)) diff --git a/adapters/dune_style_test.go b/adapters/dune_style_test.go new file mode 100644 index 00000000..e6e78127 --- /dev/null +++ b/adapters/dune_style_test.go @@ -0,0 +1,403 @@ +package adapters + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the dune-style adapter scripts directly (lib.star preloaded) over a +// shared store and virtual clock: the API-key gate, the derive-on-read +// execution lifecycle (PENDING -> EXECUTING -> COMPLETED / FAILED), the +// query-parameter model behind the 400 envelope, results paging with the +// followable next_uri, the CSV variant, and the inline-result route. +const duneAuth = "Bearer dune-api-key" + +type duneFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM +} + +func newDuneFixture(t *testing.T, start time.Time) *duneFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "dune-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &duneFixture{t: t, vc: vc, vms: map[string]*starlark.VM{ + "exec": load("execute.star"), "auth": load("auth.star"), + }} +} + +func (f *duneFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + return f.callHdrs(group, handler, method, path, params, query, body, auth, nil) +} + +// callHdrs is call plus extra request headers (e.g. X-Forwarded-Proto for a +// request that arrived through a TLS proxy). +func (f *duneFixture) callHdrs(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string, extra map[string]string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + for k, v := range extra { + headers[k] = v + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: "api.dune.test", Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// execute POSTs an execution for queryID and returns the landed execution_id. +func (f *duneFixture) execute(queryID string, body map[string]any) string { + f.t.Helper() + r := f.call("exec", "on_execute", "POST", "/api/v1/query/"+queryID+"/execute", + map[string]string{"query_id": queryID}, nil, body, duneAuth) + if r.Status != 200 { + f.t.Fatalf("execute %s -> %d: %v", queryID, r.Status, r.Body) + } + id, _ := r.Body["execution_id"].(string) + if id == "" { + f.t.Fatalf("execute %s: no execution_id: %v", queryID, r.Body) + } + return id +} + +func (f *duneFixture) status(execID string) map[string]any { + f.t.Helper() + r := f.call("exec", "on_get_status", "GET", "/api/v1/execution/"+execID+"/status", + map[string]string{"execution_id": execID}, nil, nil, duneAuth) + if r.Status != 200 { + f.t.Fatalf("status %s -> %d: %v", execID, r.Status, r.Body) + } + return r.Body +} + +func (f *duneFixture) results(execID string, query map[string]string) starlark.Response { + f.t.Helper() + return f.call("exec", "on_get_results", "GET", "/api/v1/execution/"+execID+"/results", + map[string]string{"execution_id": execID}, query, nil, duneAuth) +} + +func TestDuneAuthAndExecutionLifecycle(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDuneFixture(t, base) + + // ===== the api-key gate rejects missing, empty and non-bearer authorization ===== + // The gate is presence-only (any non-empty bearer validates, per the + // manifest's uniform token_scheme); everything else is the 401 envelope. + if r := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, ""); r.Status != 401 || r.Body["error"] != "Invalid API key" { + t.Fatalf("validate without auth -> %d %v, want 401 {error: Invalid API key}", r.Status, r.Body) + } + if r := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, "Bearer "); r.Status != 401 { + t.Fatalf("validate with empty bearer -> %d, want 401", r.Status) + } + if r := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, "Basic something"); r.Status != 401 { + t.Fatalf("validate with non-bearer auth -> %d, want 401", r.Status) + } + ok := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, "Bearer any-token-at-all") + if ok.Status != 200 || ok.Body["valid"] != true { + t.Fatalf("validate with any bearer -> %d %v, want 200 {valid: true}", ok.Status, ok.Body) + } + + // ===== an execution walks PENDING -> EXECUTING -> COMPLETED as the clock advances ===== + // Results stay 404 until the poll derives completion; the stamps and the + // 35-day result retention are derived from the injectable clock. + execID := f.execute("12345", map[string]any{"query_parameters": map[string]any{}}) + atStart := f.status(execID) + if atStart["state"] != "QUERY_STATE_PENDING" || atStart["is_execution_finished"] != false { + t.Fatalf("status at t=0 = %v", atStart) + } + if atStart["execution_started_at"] != nil || atStart["execution_ended_at"] != nil { + t.Fatalf("pending stamps = %v / %v, want null/null", atStart["execution_started_at"], atStart["execution_ended_at"]) + } + duneNum(t, atStart["query_id"], 12345, "pending query_id") + if r := f.results(execID, nil); r.Status != 404 || r.Body["error"] != "Execution is not completed yet" { + t.Fatalf("results while running -> %d %v, want 404 not-completed-yet", r.Status, r.Body) + } + + f.vc.Advance(2 * time.Second) // 2s: inside the 1s..3s executing window + running := f.status(execID) + if running["state"] != "QUERY_STATE_EXECUTING" || running["is_execution_finished"] != false { + t.Fatalf("status at 2s = %v, want EXECUTING", running) + } + if got, _ := running["execution_started_at"].(string); got != base.Add(1*time.Second).Format(time.RFC3339) { + t.Fatalf("executing execution_started_at = %v, want %s", running["execution_started_at"], base.Add(1*time.Second).Format(time.RFC3339)) + } + if running["execution_ended_at"] != nil { + t.Fatalf("executing execution_ended_at = %v, want null", running["execution_ended_at"]) + } + + f.vc.Advance(2 * time.Second) // 4s: past the 3s window + done := f.status(execID) + if done["state"] != "QUERY_STATE_COMPLETED" || done["is_execution_finished"] != true { + t.Fatalf("status at 4s = %v, want COMPLETED / finished", done) + } + if got, _ := done["execution_ended_at"].(string); got != base.Add(3*time.Second).Format(time.RFC3339) { + t.Fatalf("completed execution_ended_at = %v, want %s", done["execution_ended_at"], base.Add(3*time.Second).Format(time.RFC3339)) + } + if got, _ := done["expires_at"].(string); got != base.Add(3*time.Second).Add(35*24*time.Hour).Format(time.RFC3339) { + t.Fatalf("completed expires_at = %v, want finish + 35d retention", done["expires_at"]) + } + if r := f.results(execID, nil); r.Status != 200 { + t.Fatalf("results after completion -> %d: %v", r.Status, r.Body) + } + + // ===== a missing required parameter is the 400 envelope and both SDK parameter shapes resolve ===== + // Query 4242 declares wallet_address as required TEXT; Dune accepts both + // the plain value and the SDK {type, value} object for it. + if r := f.call("exec", "on_execute", "POST", "/api/v1/query/4242/execute", + map[string]string{"query_id": "4242"}, nil, map[string]any{"query_parameters": map[string]any{}}, duneAuth); r.Status != 400 || r.Body["error"] != "Bad Request" { + t.Fatalf("execute without required param -> %d %v, want 400 {error: Bad Request}", r.Status, r.Body) + } + sdkShape := f.execute("4242", map[string]any{"query_parameters": map[string]any{ + "wallet_address": map[string]any{"type": "TEXT", "value": "0xAbC123dEf456"}, "min_usd": 250, + }}) + plainShape := f.execute("4242", map[string]any{"query_parameters": map[string]any{ + "wallet_address": "0xFeD987aBc321", "min_usd": 250, + }}) + f.vc.Advance(4 * time.Second) + resA := f.results(sdkShape, nil) + resB := f.results(plainShape, nil) + if resA.Status != 200 || resB.Status != 200 { + t.Fatalf("parameterized results -> %d / %d", resA.Status, resB.Status) + } + duneNum(t, resA.Body["query_id"], 4242, "results query_id (integer)") + rowsA := duneRows(t, resA) + rowsB := duneRows(t, resB) + if len(rowsA) != 8 || len(rowsB) != 8 { + t.Fatalf("query 4242 rows = %d / %d, want 8 each", len(rowsA), len(rowsB)) + } + if rowsA[0]["amount_usd"] == rowsB[0]["amount_usd"] { + t.Fatalf("distinct wallet_address produced identical rows: %v", rowsA[0]["amount_usd"]) + } + + // ===== simulate_fail terminates QUERY_STATE_FAILED and results carry the failure envelope ===== + failID := f.execute("12345", map[string]any{"simulate_fail": true}) + f.vc.Advance(4 * time.Second) + failed := f.status(failID) + if failed["state"] != "QUERY_STATE_FAILED" || failed["is_execution_finished"] != true { + t.Fatalf("failed status = %v, want QUERY_STATE_FAILED / finished", failed) + } + if r := f.results(failID, nil); r.Status != 404 || r.Body["error"] != "Execution failed; no results are available" { + t.Fatalf("failed results -> %d %v, want the no-results 404", r.Status, r.Body) + } + if r := f.call("exec", "on_get_results_csv", "GET", "/api/v1/execution/"+failID+"/results/csv", + map[string]string{"execution_id": failID}, nil, nil, duneAuth); r.Status != 404 { + t.Fatalf("failed csv -> %d, want 404", r.Status) + } + // An unknown execution id answers the object-not-found 404 on both reads. + for _, h := range []string{"on_get_status", "on_get_results"} { + if r := f.call("exec", h, "GET", "/api/v1/execution/nope/"+map[string]string{ + "on_get_status": "status", "on_get_results": "results", + }[h], map[string]string{"execution_id": "nope"}, nil, nil, duneAuth); r.Status != 404 || r.Body["error"] != "Object not found" { + t.Fatalf("%s unknown execution -> %d %v, want 404 {error: Object not found}", h, r.Status, r.Body) + } + } + + // ===== the inline-result route completes synchronously ===== + // POST result returns the terminal envelope immediately, without a poll. + r := f.call("exec", "on_inline_result", "POST", "/api/v1/query/3971/result", + map[string]string{"query_id": "3971"}, nil, map[string]any{ + "query_parameters": map[string]any{"token_symbol": "WETH"}, + }, duneAuth) + if r.Status != 200 || r.Body["state"] != "QUERY_STATE_COMPLETED" || r.Body["is_execution_finished"] != true { + t.Fatalf("inline result -> %d %v, want COMPLETED / finished", r.Status, r.Body) + } + if r.Body["next_uri"] != nil || r.Body["next_offset"] != nil { + t.Fatalf("inline next_uri/next_offset = %v / %v, want null (single page)", r.Body["next_uri"], r.Body["next_offset"]) + } + inlineRows := duneRows(t, r) + if len(inlineRows) != 12 { + t.Fatalf("inline rows = %d, want 12 (query 3971)", len(inlineRows)) + } + if inlineRows[0]["token_symbol"] != "WETH" { + t.Fatalf("inline token_symbol = %v, want WETH (parameter substituted)", inlineRows[0]["token_symbol"]) + } +} + +func TestDuneResultsPagingAndCSV(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newDuneFixture(t, base) + + execID := f.execute("3971", map[string]any{"query_parameters": map[string]any{"token_symbol": "WETH"}}) + f.vc.Advance(4 * time.Second) // complete the execution + + // ===== results pages honor limit/offset with a followable next_uri ===== + // Page metadata describes the returned page; total_* the full set; the + // continuation URL is absolute on the request host so clients follow it. + p1 := f.results(execID, map[string]string{"limit": "5"}) + if p1.Status != 200 { + t.Fatalf("page 1 -> %d: %v", p1.Status, p1.Body) + } + rows1 := duneRows(t, p1) + meta1 := duneMeta(t, p1) + if len(rows1) != 5 { + t.Fatalf("page 1 rows = %d, want 5 (limit honored)", len(rows1)) + } + if rows1[0]["token_symbol"] != "WETH" { + t.Fatalf("page 1 token_symbol = %v, want WETH", rows1[0]["token_symbol"]) + } + duneNum(t, meta1["row_count"], 5, "page 1 row_count") + duneNum(t, meta1["total_row_count"], 12, "page 1 total_row_count") + duneNum(t, meta1["datapoint_count"], 48, "page 1 datapoint_count (12 rows x 4 columns)") + cols, _ := meta1["column_names"].([]any) + if len(cols) != 4 { + t.Fatalf("column_names = %v, want the 4 result columns", meta1["column_names"]) + } + duneNum(t, p1.Body["next_offset"], 5, "page 1 next_offset") + nextURI, _ := p1.Body["next_uri"].(string) + if !strings.Contains(nextURI, "/api/v1/execution/"+execID+"/results?offset=5&limit=5") { + t.Fatalf("page 1 next_uri = %q, want the offset=5&limit=5 continuation", nextURI) + } + if !strings.HasPrefix(nextURI, "http://api.dune.test/") { + t.Fatalf("page 1 next_uri = %q, want absolute http on the request host", nextURI) + } + // Behind a TLS proxy the forwarded proto keeps the continuation followable. + proxied := f.callHdrs("exec", "on_get_results", "GET", "/api/v1/execution/"+execID+"/results", + map[string]string{"execution_id": execID}, map[string]string{"limit": "5"}, + nil, duneAuth, map[string]string{"X-Forwarded-Proto": "https"}) + proxiedURI, _ := proxied.Body["next_uri"].(string) + if !strings.HasPrefix(proxiedURI, "https://api.dune.test/") { + t.Fatalf("proxied next_uri = %q, want the forwarded https scheme", proxiedURI) + } + + p2 := f.results(execID, map[string]string{"limit": "5", "offset": "5"}) + rows2 := duneRows(t, p2) + if len(rows2) != 5 { + t.Fatalf("page 2 rows = %d, want 5 (offset honored)", len(rows2)) + } + if rows2[0]["block_time"] == rows1[0]["block_time"] { + t.Fatalf("page 2 repeated page 1's first row (offset not applied)") + } + p3 := f.results(execID, map[string]string{"limit": "5", "offset": "10"}) + rows3 := duneRows(t, p3) + if len(rows3) != 2 || p3.Body["next_uri"] != nil || p3.Body["next_offset"] != nil { + t.Fatalf("final page = %d rows, next %v/%v, want 2 rows and no continuation", len(rows3), p3.Body["next_uri"], p3.Body["next_offset"]) + } + + // Same parameters reproduce the same rows: paging never re-rolls the data. + again := f.execute("3971", map[string]any{"query_parameters": map[string]any{"token_symbol": "WETH"}}) + f.vc.Advance(4 * time.Second) + replay := duneRows(t, f.results(again, nil)) + if len(replay) != 12 || replay[0]["amount_usd"] != rows1[0]["amount_usd"] { + t.Fatalf("re-executed rows differ: %v vs %v", replay[0]["amount_usd"], rows1[0]["amount_usd"]) + } + + // ===== the CSV variant streams text/csv for the same page ===== + csv := f.call("exec", "on_get_results_csv", "GET", "/api/v1/execution/"+execID+"/results/csv", + map[string]string{"execution_id": execID}, map[string]string{"limit": "3"}, nil, duneAuth) + if csv.Status != 200 { + t.Fatalf("csv -> %d", csv.Status) + } + if ct, _ := csv.Headers["Content-Type"]; ct != "text/csv" { + t.Fatalf("csv Content-Type = %q, want text/csv", ct) + } + lines := strings.Split(strings.TrimSpace(csv.RawBody), "\n") + if len(lines) != 4 { + t.Fatalf("csv lines = %d, want 4 (header + 3 rows honoring limit); body %q", len(lines), csv.RawBody) + } + if lines[0] != "block_time,protocol,amount_usd,token_symbol" { + t.Fatalf("csv header = %q, want the result column names", lines[0]) + } + if !strings.Contains(lines[1], "WETH") { + t.Fatalf("csv row 1 = %q, want WETH substitution", lines[1]) + } + if r := f.call("exec", "on_get_results_csv", "GET", "/api/v1/execution/unknown/csv", + map[string]string{"execution_id": "unknown"}, nil, nil, duneAuth); r.Status != 404 || r.Body["error"] != "Object not found" { + t.Fatalf("csv unknown execution -> %d %v, want 404 Object not found", r.Status, r.Body) + } +} + +// duneRows pulls result.rows out of a results envelope. +func duneRows(t *testing.T, r starlark.Response) []map[string]any { + t.Helper() + result, ok := r.Body["result"].(map[string]any) + if !ok { + t.Fatalf("result = %v, want object", r.Body["result"]) + } + raw, _ := result["rows"].([]any) + out := make([]map[string]any, 0, len(raw)) + for i, row := range raw { + m, ok := row.(map[string]any) + if !ok { + t.Fatalf("rows[%d] is %T, want object", i, row) + } + out = append(out, m) + } + return out +} + +// duneMeta pulls result.metadata out of a results envelope. +func duneMeta(t *testing.T, r starlark.Response) map[string]any { + t.Helper() + result, ok := r.Body["result"].(map[string]any) + if !ok { + t.Fatalf("result = %v, want object", r.Body["result"]) + } + meta, ok := result["metadata"].(map[string]any) + if !ok { + t.Fatalf("metadata = %v, want object", result["metadata"]) + } + return meta +} + +// duneNum compares a JSON number regardless of int64/float64 width (stamps +// round-trip through the collection store, where ints come back floats). +func duneNum(t *testing.T, v any, want float64, what string) { + t.Helper() + switch n := v.(type) { + case int64: + if float64(n) != want { + t.Fatalf("%s = %d, want %v", what, n, want) + } + case float64: + if n != want { + t.Fatalf("%s = %v, want %v", what, n, want) + } + default: + t.Fatalf("%s is %T(%v), want number %v", what, v, v, want) + } +} diff --git a/adapters/erc4337_style_test.go b/adapters/erc4337_style_test.go new file mode 100644 index 00000000..6366764b --- /dev/null +++ b/adapters/erc4337_style_test.go @@ -0,0 +1,404 @@ +package adapters + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the erc4337-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: the JSON-RPC 2.0 envelope +// (including batch bodies and the -32600/-32601/-32602 error codes), +// eth_estimateUserOperationGas' v0.7 field validation, the deterministic +// eth_sendUserOperation hash, the derive-on-read mempool -> bundled -> +// included lifecycle (no sleeps — the clock advances), the simulate_fail +// revert path, and the paymaster sponsorship endpoint. +const ( + ercEntryPoint = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" + ercSender = "0x1234567890abcdef1234567890abcdef12345678" +) + +type erc4337Fixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newErc4337Fixture(t *testing.T, start time.Time) *erc4337Fixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "erc4337-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &erc4337Fixture{t: t, vc: vc, host: "bundler.erc4337.test", vms: map[string]*starlark.VM{ + "rpc": load("rpc.star"), "paymaster": load("paymaster.star"), + }} +} + +// rpc posts one JSON-RPC request object to on_jsonrpc the way the engine +// dispatches POST /. +func (f *erc4337Fixture) rpc(method string, params []any, id any) map[string]any { + f.t.Helper() + r, err := f.vms["rpc"].Call("on_jsonrpc", starlark.Request{ + Method: "POST", Path: "/", Host: f.host, Headers: map[string]string{}, + Body: map[string]any{"jsonrpc": "2.0", "method": method, "params": params, "id": id}, + Params: map[string]string{}, Query: map[string]string{}, + }) + if err != nil { + f.t.Fatalf("on_jsonrpc %s: %v", method, err) + } + if r.Status != 200 { + f.t.Fatalf("on_jsonrpc %s -> HTTP %d: %v (JSON-RPC errors ride HTTP 200)", method, r.Status, r.Body) + } + return r.Body +} + +// rpcBatch posts a JSON-RPC batch (the engine wraps array bodies in +// {"_batch": [...]}) and returns the array of envelopes. +func (f *erc4337Fixture) rpcBatch(calls []map[string]any) []any { + f.t.Helper() + // The engine's JSON path yields []any, not a typed slice. + elems := make([]any, len(calls)) + for i, c := range calls { + elems[i] = c + } + r, err := f.vms["rpc"].Call("on_jsonrpc", starlark.Request{ + Method: "POST", Path: "/", Host: f.host, Headers: map[string]string{}, + Body: map[string]any{"_batch": elems}, + Params: map[string]string{}, Query: map[string]string{}, + }) + if err != nil { + f.t.Fatalf("on_jsonrpc batch: %v", err) + } + if r.Status != 200 || r.BodyList == nil { + f.t.Fatalf("batch -> HTTP %d body %v list %v, want 200 with an array body", r.Status, r.Body, r.BodyList) + } + return r.BodyList +} + +// rpcRaw posts a prebuilt body verbatim (for invalid-request shapes). +func (f *erc4337Fixture) rpcRaw(body map[string]any) map[string]any { + f.t.Helper() + r, err := f.vms["rpc"].Call("on_jsonrpc", starlark.Request{ + Method: "POST", Path: "/", Host: f.host, Headers: map[string]string{}, + Body: body, Params: map[string]string{}, Query: map[string]string{}, + }) + if err != nil { + f.t.Fatalf("on_jsonrpc raw: %v", err) + } + return r.Body +} + +// ercUserOp builds a fully valid v0.7-shaped userOperation (the adapter's +// USEROP_FIELDS, which keeps the v0.6-style paymasterAndData field). +func ercUserOp(nonce string) map[string]any { + return map[string]any{ + "sender": ercSender, + "nonce": nonce, + "initCode": "0x", + "callData": "0xdeadbeef", + "callGasLimit": "0x7d00", + "verificationGasLimit": "0x186a0", + "preVerificationGas": "0xc8", + "maxFeePerGas": "0x3b9aca00", + "maxPriorityFeePerGas": "0x1", + "paymasterAndData": "0x", + "signature": "0x" + ercRepeat("aa", 65), + } +} + +func ercRepeat(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + out += s + } + return out +} + +// ercAssertEnvelope checks the jsonrpc/version + id echo on a response. +func ercAssertEnvelope(t *testing.T, resp map[string]any, id any) { + t.Helper() + if resp["jsonrpc"] != "2.0" { + t.Fatalf("jsonrpc = %v, want \"2.0\"", resp["jsonrpc"]) + } + if resp["id"] != id { + t.Fatalf("id = %v (%T), want the request id %v echoed", resp["id"], resp["id"], id) + } +} + +// ercAssertErr asserts a JSON-RPC error envelope with the given code. +func ercAssertErr(t *testing.T, resp map[string]any, id any, code int64, messagePart string) { + t.Helper() + ercAssertEnvelope(t, resp, id) + e, ok := resp["error"].(map[string]any) + if !ok { + t.Fatalf("want an error object, got %v", resp) + } + if got := ercNum(e["code"]); got != code { + t.Fatalf("error code = %v, want %d (%v)", e["code"], code, e) + } + if msg, _ := e["message"].(string); !strings.Contains(msg, messagePart) { + t.Fatalf("error message = %q, want it to contain %q", msg, messagePart) + } + if _, hasResult := resp["result"]; hasResult { + t.Fatalf("error response also carries a result: %v", resp) + } +} + +// ercNum reads a response number whether the adapter produced a fresh +// Starlark int or a value that round-tripped the JSON document store. +func ercNum(v any) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + } + return 0 +} + +// TestErc4337BundlerRPC: the JSON-RPC bundler surface. +func TestErc4337BundlerRPC(t *testing.T) { + f := newErc4337Fixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC)) + + // ===== supportedEntryPoints, chainId, and the JSON-RPC envelope ===== + eps := f.rpc("eth_supportedEntryPoints", []any{}, int64(1)) + ercAssertEnvelope(t, eps, int64(1)) + if list, ok := eps["result"].([]any); !ok || len(list) != 1 || list[0] != ercEntryPoint { + t.Fatalf("supportedEntryPoints = %v, want [%s]", eps["result"], ercEntryPoint) + } + if r := f.rpc("eth_chainId", nil, "chain-1"); r["result"] != "0x1" { + t.Fatalf("eth_chainId = %v, want 0x1", r["result"]) + } + ercAssertErr(t, f.rpc("eth_bogusMethod", []any{}, int64(7)), int64(7), -32601, "does not exist/is not available") + ercAssertErr(t, f.rpcRaw(map[string]any{"params": []any{}}), nil, -32600, "Invalid Request") + // Batch bodies answer element-by-element in order. + batch := f.rpcBatch([]map[string]any{ + {"jsonrpc": "2.0", "method": "eth_supportedEntryPoints", "params": []any{}, "id": int64(11)}, + {"jsonrpc": "2.0", "method": "eth_bogusMethod", "params": []any{}, "id": int64(12)}, + }) + if len(batch) != 2 { + t.Fatalf("batch = %d envelopes, want 2", len(batch)) + } + first, _ := batch[0].(map[string]any) + second, _ := batch[1].(map[string]any) + if list, _ := first["result"].([]any); len(list) != 1 || list[0] != ercEntryPoint { + t.Fatalf("batch[0] = %v, want the entry points result", first) + } + if e, _ := second["error"].(map[string]any); ercNum(e["code"]) != -32601 { + t.Fatalf("batch[1] = %v, want the -32601 method-not-found envelope", second) + } + + // ===== estimateUserOperationGas validates the full v0.7 field set ===== + gas := f.rpc("eth_estimateUserOperationGas", []any{ercUserOp("0x0"), ercEntryPoint}, int64(2)) + ercAssertEnvelope(t, gas, int64(2)) + est, ok := gas["result"].(map[string]any) + if !ok { + t.Fatalf("estimateGas result = %v, want an object", gas["result"]) + } + for field, want := range map[string]string{ + "preVerificationGas": "0xc8", "verificationGasLimit": "0x186a0", "callGasLimit": "0x7d00", + } { + if est[field] != want { + t.Fatalf("estimateGas %s = %v, want the fixed %s", field, est[field], want) + } + } + missing := ercUserOp("0x0") + delete(missing, "callData") + ercAssertErr(t, f.rpc("eth_estimateUserOperationGas", []any{missing, ercEntryPoint}, int64(3)), + int64(3), -32602, "missing required field: callData") + nullSig := ercUserOp("0x0") + nullSig["signature"] = nil + ercAssertErr(t, f.rpc("eth_estimateUserOperationGas", []any{nullSig, ercEntryPoint}, int64(4)), + int64(4), -32602, "null value for required field: signature") + ercAssertErr(t, f.rpc("eth_estimateUserOperationGas", []any{}, int64(5)), + int64(5), -32602, "missing userOperation") + + // ===== sendUserOperation answers a deterministic hash and defaults the entry point ===== + sent := f.rpc("eth_sendUserOperation", []any{ercUserOp("0x0"), ercEntryPoint}, int64(6)) + ercAssertEnvelope(t, sent, int64(6)) + userOpHash, _ := sent["result"].(string) + if len(userOpHash) != 66 || !strings.HasPrefix(userOpHash, "0x") { + t.Fatalf("userOpHash = %q, want 0x + 64 hex chars", userOpHash) + } + // Omitting the entryPoint param defaults to v0.7 — same hash. + defaulted := f.rpc("eth_sendUserOperation", []any{ercUserOp("0x0")}, int64(7)) + if defaulted["result"] != userOpHash { + t.Fatalf("defaulted entry point hash = %v, want the same %v", defaulted["result"], userOpHash) + } + if again := f.rpc("eth_sendUserOperation", []any{ercUserOp("0x0"), ercEntryPoint}, int64(8)); again["result"] != userOpHash { + t.Fatalf("resend hash = %v, want the deterministic %v", again["result"], userOpHash) + } + badOp := map[string]any{"sender": ercSender, "nonce": "0x0"} + ercAssertErr(t, f.rpc("eth_sendUserOperation", []any{badOp, ercEntryPoint}, int64(9)), + int64(9), -32602, "missing required field:") + + // ===== the op walks mempool -> bundled -> included on the virtual clock ===== + byHash := func(hash string) map[string]any { + t.Helper() + resp := f.rpc("eth_getUserOperationByHash", []any{hash}, int64(20)) + ercAssertEnvelope(t, resp, int64(20)) + return resp + } + receipt := func(hash string) map[string]any { + t.Helper() + resp := f.rpc("eth_getUserOperationReceipt", []any{hash}, int64(21)) + ercAssertEnvelope(t, resp, int64(21)) + return resp + } + // 0-1s: in the mempool, not yet addressable by hash. + if r := byHash(userOpHash)["result"]; r != nil { + t.Fatalf("byHash in mempool = %v, want null", r) + } + if r := receipt(userOpHash)["result"]; r != nil { + t.Fatalf("receipt in mempool = %v, want null", r) + } + // 1-3s: bundled — visible, but no block placement and no receipt yet. + f.vc.Advance(2 * time.Second) + bundled, ok := byHash(userOpHash)["result"].(map[string]any) + if !ok { + t.Fatalf("byHash at +2s = %v, want the bundled shape", byHash(userOpHash)["result"]) + } + op, ok := bundled["userOperation"].(map[string]any) + if !ok || op["sender"] != ercSender || op["callData"] != "0xdeadbeef" { + t.Fatalf("bundled userOperation = %v, want the stored op echoed", bundled["userOperation"]) + } + if bundled["blockNumber"] != nil || bundled["blockHash"] != nil || bundled["transactionHash"] != nil { + t.Fatalf("bundled placement = %v, want null block fields until inclusion", bundled) + } + if r := receipt(userOpHash)["result"]; r != nil { + t.Fatalf("receipt while bundled = %v, want null until inclusion", r) + } + // >=3s: included — full on-chain shapes, and repeated polls agree. + f.vc.Advance(2 * time.Second) + inc, ok := receipt(userOpHash)["result"].(map[string]any) + if !ok { + t.Fatalf("receipt after inclusion = %v, want the full shape", receipt(userOpHash)["result"]) + } + if inc["userOpHash"] != userOpHash || inc["sender"] != ercSender || inc["success"] != true { + t.Fatalf("receipt = %v, want success:true echoing hash and sender", inc) + } + if inc["actualGasCost"] != "0x186a0" || inc["actualGasUsed"] != "0xc350" { + t.Fatalf("receipt gas = %v/%v, want the fixed hex values", inc["actualGasCost"], inc["actualGasUsed"]) + } + logs, ok := inc["logs"].([]any) + if !ok || len(logs) != 1 { + t.Fatalf("receipt logs = %v, want the single UserOperationEvent log", inc["logs"]) + } + log0, _ := logs[0].(map[string]any) + topics, _ := log0["topics"].([]any) + if len(topics) != 1 || !strings.HasPrefix(topics[0].(string), "0x") { + t.Fatalf("log topics = %v, want the event topic hash", log0["topics"]) + } + if inc["reason"] != nil { + t.Fatalf("successful receipt carries a reason: %v", inc) + } + mined, ok := byHash(userOpHash)["result"].(map[string]any) + if !ok || mined["blockNumber"] != "0x1" { + t.Fatalf("byHash after inclusion = %v, want blockNumber 0x1", byHash(userOpHash)["result"]) + } + if txHash, _ := mined["transactionHash"].(string); len(txHash) != 66 { + t.Fatalf("byHash transactionHash = %v, want 0x + 64 hex", mined["transactionHash"]) + } + // Unknown hashes resolve to null results, not errors. + if r := receipt("0x" + ercRepeat("ab", 32))["result"]; r != nil { + t.Fatalf("unknown hash receipt = %v, want null", r) + } + + // ===== simulate_fail reverts on inclusion with the AA95 reason ===== + failOp := ercUserOp("0x1") + failOp["callData"] = "0xdeadbeed" + failSend := f.rpc("eth_sendUserOperation", []any{failOp, ercEntryPoint, map[string]any{"simulate_fail": true}}, int64(30)) + failHash, _ := failSend["result"].(string) + if failHash == "" || failHash == userOpHash { + t.Fatalf("simulate_fail hash = %q, want a distinct non-empty hash", failHash) + } + f.vc.Advance(4 * time.Second) + failReceipt, ok := receipt(failHash)["result"].(map[string]any) + if !ok { + t.Fatalf("simulate_fail receipt = %v, want the reverted shape", receipt(failHash)["result"]) + } + if failReceipt["success"] != false { + t.Fatalf("simulate_fail success = %v, want false", failReceipt["success"]) + } + if failReceipt["reason"] != "AA95 user operation execution reverted" { + t.Fatalf("simulate_fail reason = %v, want the AA95 revert reason", failReceipt["reason"]) + } +} + +// TestErc4337PaymasterSign: the sponsorship-signing REST side endpoint. +func TestErc4337PaymasterSign(t *testing.T) { + f := newErc4337Fixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC)) + sign := func(body map[string]any) starlark.Response { + t.Helper() + r, err := f.vms["paymaster"].Call("on_sign", starlark.Request{ + Method: "POST", Path: "/paymaster/sign", Host: f.host, Headers: map[string]string{}, + Body: body, Params: map[string]string{}, Query: map[string]string{}, + }) + if err != nil { + t.Fatalf("on_sign: %v", err) + } + return r + } + + // ===== the paymaster signs the op into paymasterAndData ===== + ok := sign(map[string]any{"userOp": ercUserOp("0x0")}) + if ok.Status != 200 { + t.Fatalf("paymaster/sign -> %d: %v", ok.Status, ok.Body) + } + pmData, _ := ok.Body["paymasterAndData"].(string) + if !strings.HasPrefix(pmData, "0x0000000000000000000000000000000000000001") || len(pmData) < 42 { + t.Fatalf("paymasterAndData = %q, want the mock paymaster address prefix", pmData) + } + if ok.Body["validUntil"] != "0x0" || ok.Body["validAfter"] != "0x0" { + t.Fatalf("validity = %v/%v, want zero windows", ok.Body["validUntil"], ok.Body["validAfter"]) + } + + // ===== missing or invalid userOps are 400s ===== + if r := sign(map[string]any{}); r.Status != 400 || r.Body["error"] != "missing_userOp" { + t.Fatalf("sign without userOp -> %d %v, want 400 missing_userOp", r.Status, r.Body) + } + partial := sign(map[string]any{"userOp": map[string]any{"sender": ercSender}}) + if partial.Status != 400 || partial.Body["error"] != "invalid_userOp" { + t.Fatalf("sign with partial userOp -> %d %v, want 400 invalid_userOp", partial.Status, partial.Body) + } + if msg, _ := partial.Body["message"].(string); !strings.Contains(msg, "missing required field:") { + t.Fatalf("invalid_userOp message = %q, want the field-validation detail", msg) + } +} diff --git a/adapters/etherscan-style/README.md b/adapters/etherscan-style/README.md index 5fcbf9dd..b7c90e6b 100644 --- a/adapters/etherscan-style/README.md +++ b/adapters/etherscan-style/README.md @@ -18,7 +18,7 @@ lookups, contract verification, and chain stats. ### Auth Auth is via the `apikey` query parameter. The mock accepts any non-empty -value. A missing `apikey` returns `{status: "0", message: "Missing API key"}`. +value. A missing `apikey` returns `{status: "0", message: "NOTOK", result: "Missing API Key"}`. ### Modules & Actions diff --git a/adapters/etherscan-style/scripts/lib.star b/adapters/etherscan-style/scripts/lib.star index 970066b4..26c94468 100644 --- a/adapters/etherscan-style/scripts/lib.star +++ b/adapters/etherscan-style/scripts/lib.star @@ -10,9 +10,11 @@ def _ok(result): return {"status": "1", "message": "OK", "result": result} -# _err wraps an error in the Etherscan envelope. +# _err wraps an error in the Etherscan envelope: message stays "NOTOK" and +# result carries the human description (the real wire shape — Etherscan +# answers {"status":"0","message":"NOTOK","result":"Missing API Key"}). def _err(message): - return {"status": "0", "message": message, "result": ""} + return {"status": "0", "message": "NOTOK", "result": message} # --- auth --- @@ -21,7 +23,7 @@ def _err(message): def _require_apikey(req): apikey = req["query"].get("apikey", "") if apikey == None or apikey == "": - return respond(200, {"status": "0", "message": "Missing API key", "result": []}) + return respond(200, _err("Missing API Key")) return None # --- deterministic hashing (same as eth-jsonrpc for cross-adapter fidelity) --- diff --git a/adapters/etherscan_style_test.go b/adapters/etherscan_style_test.go new file mode 100644 index 00000000..30e7bf86 --- /dev/null +++ b/adapters/etherscan_style_test.go @@ -0,0 +1,259 @@ +package adapters + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the etherscan-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: the apikey query-parameter gate, +// the module/action grammar with the NOTOK error envelope, the seeded +// account/contract/token-holder ledger (every number a decimal string), +// and txlist's startblock/endblock/filter_by/sort/page/offset parameters. +const ( + esKey = "mock-etherscan-key" + esZero = "0x0000000000000000000000000000000000000000" + esOne = "0x0000000000000000000000000000000000000001" + esTwo = "0x0000000000000000000000000000000000000002" + esMockToken = "0x0000000000000000000000000000000000000100" +) + +type etherscanFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newEtherscanFixture(t *testing.T, start time.Time) *etherscanFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "etherscan-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + src, err := os.ReadFile(filepath.Join(root, "scripts", "api.star")) + if err != nil { + t.Fatalf("read api.star: %v", err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib api.star: %v", err) + } + return ðerscanFixture{t: t, vc: vc, host: "api.etherscan.test", vms: map[string]*starlark.VM{"api": vm}} +} + +// get drives the single GET /api handler with the module/action query +// grammar; apikey is added unless withKey is false. +func (f *etherscanFixture) get(query map[string]string, withKey bool) starlark.Response { + f.t.Helper() + q := map[string]string{} + for k, v := range query { + q[k] = v + } + if withKey { + q["apikey"] = esKey + } + resp, err := f.vms["api"].Call("on_api", starlark.Request{ + Method: "GET", Path: "/api", Host: f.host, Headers: map[string]string{}, + Params: map[string]string{}, Query: q, + }) + if err != nil { + f.t.Fatalf("on_api %v: %v", query, err) + } + return resp +} + +// esEnvelope unwraps the {status, message, result} envelope after checking +// the HTTP status (Etherscan always answers 200) and the status/message +// pair. +func esEnvelope(t *testing.T, r starlark.Response, wantStatus, wantMessage string) any { + t.Helper() + if r.Status != 200 { + t.Fatalf("GET /api -> HTTP %d: %v (Etherscan answers HTTP 200 for in-band errors too)", r.Status, r.Body) + } + if r.Body["status"] != wantStatus { + t.Fatalf("status = %v (%T), want %q; envelope %v", r.Body["status"], r.Body["status"], wantStatus, r.Body) + } + if r.Body["message"] != wantMessage { + t.Fatalf("message = %v, want %q", r.Body["message"], wantMessage) + } + return r.Body["result"] +} + +// esResultList asserts the result is an array and returns it. +func esResultList(t *testing.T, result any) []any { + t.Helper() + arr, ok := result.([]any) + if !ok { + t.Fatalf("result = %v (%T), want an array", result, result) + } + return arr +} + +func TestEtherscanModuleActionGrammar(t *testing.T) { + f := newEtherscanFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC)) + + // ===== the apikey query parameter gates every module call ===== + // Real Etherscan keeps message "NOTOK" and carries the description in + // result; only a MISSING key errors (any non-empty value is accepted). + noKey := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance", "address": esZero}, false), "0", "NOTOK") + if noKey != "Missing API Key" { + t.Fatalf("missing-key result = %v (%T), want the \"Missing API Key\" string", noKey, noKey) + } + if r := f.get(map[string]string{"module": "account", "action": "balance", "address": esZero}, true); r.Body["status"] != "1" { + t.Fatalf("any non-empty apikey rejected: %v", r.Body) + } + + // ===== unknown modules and actions answer the NOTOK envelope over HTTP 200 ===== + if r := esEnvelope(t, f.get(map[string]string{"module": "nomodule", "action": "balance"}, true), "0", "NOTOK"); r != "Invalid module" { + t.Fatalf("unknown module result = %v, want \"Invalid module\"", r) + } + if r := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "bogus"}, true), "0", "NOTOK"); r != "Invalid account action" { + t.Fatalf("unknown action result = %v, want \"Invalid account action\"", r) + } + if r := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance"}, true), "0", "NOTOK"); r != "Missing address" { + t.Fatalf("balance without address result = %v, want \"Missing address\"", r) + } + + // ===== balance reads the seeded ledger; unknown addresses default to "0" ===== + bal := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance", "address": esZero, "tag": "latest"}, true), "1", "OK") + if bal != "1000000000000000000000" { + t.Fatalf("zero-address balance = %v (%T), want the seeded wei string", bal, bal) + } + if unknown := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance", "address": "0x9999999999999999999999999999999999999999", "tag": "latest"}, true), "1", "OK"); unknown != "0" { + t.Fatalf("unknown-address balance = %v, want \"0\"", unknown) + } + multi := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "balancemulti", + "address": esZero + "," + esOne + "," + esTwo, + }, true), "1", "OK")) + if len(multi) != 3 { + t.Fatalf("balancemulti = %d rows, want 3 (one per comma-separated address)", len(multi)) + } + row, _ := multi[1].(map[string]any) + if row["account"] != esOne || row["balance"] != "500000000000000000000" { + t.Fatalf("balancemulti[1] = %v, want account/balance echo for %s", row, esOne) + } + + // ===== txlist scopes by address then applies block filters, sort and paging ===== + // 0x…0002 touches both seeded txs (to seed-1, from seed-2). + txAddr := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "txlist", "address": esTwo, + }, true), "1", "OK")) + if len(txAddr) != 2 { + t.Fatalf("txlist for %s = %d rows, want 2 (to seed-1, from seed-2)", esTwo, len(txAddr)) + } + first, _ := txAddr[0].(map[string]any) + if first["from"] != esOne || first["value"] != "1000000000000000000" || first["isError"] != "0" { + t.Fatalf("txlist[0] = %v, want the seed-1 wire shape (numbers as strings)", first) + } + // filter_by narrows the address scope to one side. + fromOnly := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "txlist", "address": esTwo, "filter_by": "from", + }, true), "1", "OK")) + if len(fromOnly) != 1 || fromOnly[0].(map[string]any)["blockNumber"] != "2" { + t.Fatalf("filter_by=from = %v, want only the block-2 tx", fromOnly) + } + // startblock filters numerically on the stored decimal-string blockNumber. + late := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "txlist", "address": esTwo, "startblock": "2", + }, true), "1", "OK")) + if len(late) != 1 || late[0].(map[string]any)["timeStamp"] != "1700000001" { + t.Fatalf("startblock=2 = %v, want only the block-2 tx", late) + } + // sort=desc orders by timeStamp newest-first; page/offset slice. + desc := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "txlist", "address": esTwo, "sort": "desc", + }, true), "1", "OK")) + if len(desc) != 2 || desc[0].(map[string]any)["timeStamp"] != "1700000001" { + t.Fatalf("sort=desc = %v, want the newer tx first", desc) + } + page1 := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "txlist", "address": esTwo, "page": "1", "offset": "1", + }, true), "1", "OK")) + if len(page1) != 1 || page1[0].(map[string]any)["timeStamp"] != "1700000000" { + t.Fatalf("page=1 offset=1 = %v, want exactly the first seeded tx", page1) + } + page2 := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "account", "action": "txlist", "address": esTwo, "page": "2", "offset": "1", + }, true), "1", "OK")) + if len(page2) != 1 || page2[0].(map[string]any)["timeStamp"] != "1700000001" { + t.Fatalf("page=2 offset=1 = %v, want exactly the second seeded tx", page2) + } + + // ===== contract verification: ABI, source, and the unverified fallback ===== + abiStr, ok := esEnvelope(t, f.get(map[string]string{ + "module": "contract", "action": "getabi", "address": esMockToken, + }, true), "1", "OK").(string) + if !ok || len(abiStr) == 0 || abiStr[0] != '[' { + t.Fatalf("getabi result = %v, want a JSON ABI string starting with '['", abiStr) + } + var abi []map[string]any + if err := json.Unmarshal([]byte(abiStr), &abi); err != nil || len(abi) == 0 { + t.Fatalf("getabi result is not a JSON array of functions: %v (err %v)", abiStr, err) + } + src := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "contract", "action": "getsourcecode", "address": esMockToken, + }, true), "1", "OK")) + meta, _ := src[0].(map[string]any) + if meta["ContractName"] != "MockToken" || meta["CompilerVersion"] != "v0.8.20+commit.a1b79de6" || meta["Proxy"] != "0" { + t.Fatalf("getsourcecode[0] = %v, want the seeded MockToken verification record", meta) + } + unverified := esEnvelope(t, f.get(map[string]string{ + "module": "contract", "action": "getabi", "address": "0x1234567890abcdef1234567890abcdef12345678", + }, true), "0", "NOTOK") + if unverified != "Contract source code not verified" { + t.Fatalf("unverified getabi = %v, want the not-verified description", unverified) + } + // getsourcecode stays status "1" for unverified contracts, with the + // notice carried inside the ABI field — the real Etherscan quirk. + fallback := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "contract", "action": "getsourcecode", "address": "0x1234567890abcdef1234567890abcdef12345678", + }, true), "1", "OK")) + if fb, _ := fallback[0].(map[string]any); fb["ABI"] != "Contract source code not verified" || fb["ContractName"] != "" { + t.Fatalf("unverified getsourcecode = %v, want the empty record with the ABI notice", fb) + } + + // ===== stats and token holders keep every number a decimal string ===== + price, ok := esEnvelope(t, f.get(map[string]string{"module": "stats", "action": "ethprice"}, true), "1", "OK").(map[string]any) + if !ok || price["ethusd"] != "2500.00" || price["ethbtc"] != "15.0" { + t.Fatalf("ethprice = %v, want the seeded string-valued prices", price) + } + if supply := esEnvelope(t, f.get(map[string]string{"module": "stats", "action": "ethsupply"}, true), "1", "OK"); supply != "120000000000000000000000000" { + t.Fatalf("ethsupply = %v (%T), want the wei string", supply, supply) + } + holders := esResultList(t, esEnvelope(t, f.get(map[string]string{ + "module": "token", "action": "tokenholderlist", "contractaddress": esMockToken, + "page": "2", "offset": "1", + }, true), "1", "OK")) + if len(holders) != 1 { + t.Fatalf("tokenholderlist page 2 offset 1 = %d rows, want 1", len(holders)) + } + if h, _ := holders[0].(map[string]any); h["TokenHolderAddress"] != esTwo || h["TokenHolderQuantity"] != "250000000000000000000" { + t.Fatalf("tokenholderlist page 2 = %v, want the second seeded holder", h) + } +} diff --git a/adapters/jumio_style_test.go b/adapters/jumio_style_test.go new file mode 100644 index 00000000..e10d2cc2 --- /dev/null +++ b/adapters/jumio_style_test.go @@ -0,0 +1,564 @@ +package adapters + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the jumio-style adapter scripts directly (lib.star preloaded) over +// a shared store, virtual clock and webhook sink: the Bearer gate and the +// Netverify retrieval-v2 response shapes, the derive-on-read scan lifecycle +// (PENDING -> DONE | FAILED with real reject-reason codes), the extracted +// document data (None while PENDING, 409 when FAILED), scan deletion with +// its advance-then-remove semantics, and the X-Jumio-Webhook-Signature HMAC +// scheme in both directions. +const ( + jumioAuth = "Bearer jumio_stunt_test_token" + jumioHost = "netverify.jumio.test" + jumioWebhookSecret = "stunt_jumio_mock_signing_key" +) + +// jumioScanRefRE pins the synthetic scan-reference shape: decimal groups +// assembled at runtime (as-is: real Netverify scan references are UUIDs). +var jumioScanRefRE = regexp.MustCompile(`^\d{9}-0000-4000-8000-\d{9}$`) + +type jumioFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + emitter *events.Emitter + host string +} + +func newJumioFixture(t *testing.T, start time.Time) *jumioFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "jumio-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + em := events.NewEmitter() + t.Cleanup(em.Close) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em, + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &jumioFixture{t: t, vc: vc, vms: map[string]*starlark.VM{ + "scans": load("scans.star"), "hooks": load("webhooks.star"), + }, emitter: em, host: jumioHost} +} + +// call invokes handler on the named script VM; auth is the full Authorization +// header value ("" = header absent). +func (f *jumioFixture) call(group, handler, method, path string, params map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// jumioCreate posts a scan create and returns the minted scan reference. +func (f *jumioFixture) jumioCreate(body map[string]any) string { + f.t.Helper() + r := f.call("scans", "on_create_scan", "POST", "/netverify/v2/scans", nil, body, jumioAuth) + if r.Status != 200 { + f.t.Fatalf("create scan -> %d: %v", r.Status, r.Body) + } + ref, _ := r.Body["scanReference"].(string) + if ref == "" { + f.t.Fatalf("create scan: no scanReference: %v", r.Body) + } + return ref +} + +// jumioStatus reads GET /netverify/v2/scans/{ref} and returns its status. +func (f *jumioFixture) jumioStatus(ref string) string { + f.t.Helper() + r := f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+ref, + map[string]string{"scan_reference": ref}, nil, jumioAuth) + if r.Status != 200 { + f.t.Fatalf("get scan %s -> %d: %v", ref, r.Status, r.Body) + } + return r.Body["status"].(string) +} + +// jumioNum compares a response number against want whether it arrives as an +// int (handler literal) or a float (round-tripped through a collection). +func jumioNum(got any, want int64) bool { + switch n := got.(type) { + case int64: + return n == want + case float64: + return n == float64(want) + } + return false +} + +// jumioSeq extracts the trailing sequence group from a scan reference. +func jumioSeq(t *testing.T, ref string) int64 { + t.Helper() + groups := strings.Split(ref, "-") + n, err := strconv.ParseInt(groups[len(groups)-1], 10, 64) + if err != nil { + t.Fatalf("scan reference %q: trailing group unparsable: %v", ref, err) + } + return n +} + +// jumioDelivery is one webhook POST captured by the sink. +type jumioDelivery struct { + body []byte + sig string +} + +// envelope returns the delivery's parsed {type, payload} envelope. +func (d jumioDelivery) envelope(t *testing.T) (string, map[string]any) { + t.Helper() + var env struct { + Type string `json:"type"` + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(d.body, &env); err != nil { + t.Fatalf("webhook body %s unparsable: %v", d.body, err) + } + if env.Type == "" || env.Payload == nil { + t.Fatalf("webhook body %s, want a {type, payload} envelope", d.body) + } + return env.Type, env.Payload +} + +// captureWebhooks registers a sink and returns a collector over the raw +// deliveries (body + X-Jumio-Webhook-Signature header). +func (f *jumioFixture) captureWebhooks() func() []jumioDelivery { + f.t.Helper() + var mu sync.Mutex + var got []jumioDelivery + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + got = append(got, jumioDelivery{body: b, sig: r.Header.Get("X-Jumio-Webhook-Signature")}) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + f.t.Cleanup(sink.Close) + f.emitter.Register("test", sink.URL) + return func() []jumioDelivery { + mu.Lock() + defer mu.Unlock() + return append([]jumioDelivery(nil), got...) + } +} + +// jumioVerifyDelivery checks X-Jumio-Webhook-Signature against Jumio's +// scheme — hex(HMAC-SHA256(secret, raw_body)) — over the exact bytes the +// sink received. +func jumioVerifyDelivery(t *testing.T, d jumioDelivery) { + t.Helper() + mac := hmac.New(sha256.New, []byte(jumioWebhookSecret)) + mac.Write(d.body) + want := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(d.sig)) { + t.Fatalf("X-Jumio-Webhook-Signature = %q, want %q over body %s", d.sig, want, d.body) + } +} + +// TestJumioScanCreateAndGate: the Bearer gate's Jumio error envelope, the +// create response shape with its synthetic decimal scan references, and the +// merchantScanReference requirement. +func TestJumioScanCreateAndGate(t *testing.T) { + f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC()) + create := func(body map[string]any, auth string) starlark.Response { + return f.call("scans", "on_create_scan", "POST", "/netverify/v2/scans", nil, body, auth) + } + + // ===== a missing, bare or wrong-scheme token is a 401 in the Jumio error envelope ===== + // {httpStatus: 401, message: "Unauthorized"}. + for _, auth := range []string{"", "jumio_stunt_test_token", "Basic abc"} { + r := create(map[string]any{"merchantScanReference": "ref-1"}, auth) + if r.Status != 401 { + t.Fatalf("auth %q -> %d, want 401", auth, r.Status) + } + if !jumioNum(r.Body["httpStatus"], 401) || r.Body["message"] != "Unauthorized" { + t.Fatalf("401 envelope = %v, want httpStatus 401 Unauthorized", r.Body) + } + } + // Any Bearer is accepted (as-is): real Jumio uses HTTP Basic auth against + // a server-token store; this gate checks Bearer presence only. + if r := create(map[string]any{"merchantScanReference": "ref-1"}, "Bearer totally-unknown"); r.Status != 200 { + t.Fatalf("any bearer -> %d, want 200 (presence-only gate)", r.Status) + } + + // ===== a scan create answers PENDING with a synthetic decimal scan reference ===== + r := create(map[string]any{ + "merchantScanReference": "merchant-ref-001", "country": "USA", "type": "DRIVING_LICENSE", + }, jumioAuth) + if r.Status != 200 { + t.Fatalf("create scan -> %d: %v", r.Status, r.Body) + } + ref, _ := r.Body["scanReference"].(string) + if !jumioScanRefRE.MatchString(ref) { + t.Fatalf("scanReference = %q, want the decimal-group synthetic shape", ref) + } + if r.Body["status"] != "PENDING" || r.Body["merchantScanReference"] != "merchant-ref-001" { + t.Fatalf("create response = %v, want PENDING + the merchant reference echoed", r.Body) + } + if _, err := time.Parse(time.RFC3339, r.Body["timestamp"].(string)); err != nil { + t.Fatalf("timestamp = %v, want an RFC3339 timestamp", r.Body["timestamp"]) + } + + // ===== sequential creates advance the reference sequence ===== + second, _ := create(map[string]any{"merchantScanReference": "merchant-ref-002"}, jumioAuth).Body["scanReference"].(string) + if a, b := jumioSeq(t, ref), jumioSeq(t, second); b <= a { + t.Fatalf("scan sequence %d then %d, want monotonically increasing", a, b) + } + + // ===== a create without merchantScanReference is a 400 ===== + for name, body := range map[string]map[string]any{"no ref": {}, "nil body": nil} { + r := create(body, jumioAuth) + if r.Status != 400 { + t.Fatalf("create %s -> %d, want 400", name, r.Status) + } + if !jumioNum(r.Body["httpStatus"], 400) || r.Body["message"] != "merchantScanReference is required" { + t.Fatalf("create %s error = %v, want the required-field message", name, r.Body) + } + } +} + +// TestJumioScanStatusLifecycle: the derive-on-read status machine on the +// virtual clock — PENDING through the whole processing window, DONE at +3s +// (persisted), FAILED with real reject-reason codes, and the 404s. +func TestJumioScanStatusLifecycle(t *testing.T) { + f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC()) + + // ===== PENDING holds through the processing window then flips to DONE ===== + // Jumio reports no distinct in-flight state: 0..3s is all PENDING. + ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001"}) + if s := f.jumioStatus(ref); s != "PENDING" { + t.Fatalf("fresh status = %q, want PENDING", s) + } + f.vc.Advance(2 * time.Second) + if s := f.jumioStatus(ref); s != "PENDING" { + t.Fatalf("status at +2s = %q, want still PENDING", s) + } + f.vc.Advance(2 * time.Second) + if s := f.jumioStatus(ref); s != "DONE" { + t.Fatalf("status at +4s = %q, want DONE", s) + } + r := f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+ref, + map[string]string{"scan_reference": ref}, nil, jumioAuth) + if r.Body["status"] != "DONE" || r.Body["scanReference"] != ref || + r.Body["merchantScanReference"] != "merchant-ref-001" { + t.Fatalf("DONE scan = %v, want the retrieval shape with the reference echoed", r.Body) + } + if _, has := r.Body["rejectionReason"]; has { + t.Fatalf("DONE scan carries rejectionReason = %v, want none", r.Body["rejectionReason"]) + } + + // ===== a FAILED scan carries a real reject reason and its description ===== + // simulate_reject_reason implies failure; simulate_fail alone defaults to + // MANIPULATED_DOCUMENT (both stunt-only create fields). + rejectRef := f.jumioCreate(map[string]any{ + "merchantScanReference": "merchant-ref-002", "simulate_reject_reason": "DOCUMENT_EXPIRED", + }) + defaultRef := f.jumioCreate(map[string]any{ + "merchantScanReference": "merchant-ref-003", "simulate_fail": true, + }) + f.vc.Advance(4 * time.Second) + r = f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+rejectRef, + map[string]string{"scan_reference": rejectRef}, nil, jumioAuth) + if r.Body["status"] != "FAILED" || r.Body["rejectionReason"] != "DOCUMENT_EXPIRED" || + r.Body["rejectReasonDescription"] != "The document has expired." { + t.Fatalf("rejected scan = %v, want FAILED DOCUMENT_EXPIRED with its description", r.Body) + } + r = f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+defaultRef, + map[string]string{"scan_reference": defaultRef}, nil, jumioAuth) + if r.Body["status"] != "FAILED" || r.Body["rejectionReason"] != "MANIPULATED_DOCUMENT" { + t.Fatalf("default-fail scan = %v, want FAILED MANIPULATED_DOCUMENT", r.Body) + } + if desc, _ := r.Body["rejectReasonDescription"].(string); desc == "" { + t.Fatalf("default-fail description = %v, want non-empty", r.Body["rejectReasonDescription"]) + } + + // ===== unknown scans are 404 on every parameterized route ===== + for _, rt := range []struct { + handler, method, path string + }{ + {"on_get_scan", "GET", "/netverify/v2/scans/nope-0000-4000-8000-000000000"}, + {"on_delete_scan", "DELETE", "/netverify/v2/scans/nope-0000-4000-8000-000000000"}, + {"on_get_scan_data", "GET", "/netverify/v2/scans/nope-0000-4000-8000-000000000/data"}, + } { + r := f.call("scans", rt.handler, rt.method, rt.path, + map[string]string{"scan_reference": "nope-0000-4000-8000-000000000"}, nil, jumioAuth) + if r.Status != 404 || !jumioNum(r.Body["httpStatus"], 404) { + t.Fatalf("%s %s -> %d %v, want the 404 error envelope", rt.method, rt.path, r.Status, r.Body) + } + } +} + +// TestJumioScanExtractedData: the retrieval-v2 data shapes — extractedData is +// absent while PENDING, the synthetic document fields once DONE, and the 409 +// (with the rejection reason repeated) when FAILED. +func TestJumioScanExtractedData(t *testing.T) { + f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC()) + scanData := func(ref string) starlark.Response { + f.t.Helper() + return f.call("scans", "on_get_scan_data", "GET", "/netverify/v2/scans/"+ref+"/data", + map[string]string{"scan_reference": ref}, nil, jumioAuth) + } + + // ===== extracted data is None while the scan is PENDING ===== + ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001", "country": "GBR"}) + r := scanData(ref) + if r.Status != 200 || r.Body["status"] != "PENDING" { + t.Fatalf("pending scan data -> %d %v, want 200 PENDING", r.Status, r.Body) + } + if got := r.Body["extractedData"]; got != nil { + t.Fatalf("pending extractedData = %v, want null", got) + } + + // ===== DONE exposes the synthetic document extraction ===== + // Fixed synthetic PII (JOHN DOE); the document number embeds the scan's + // read count at extraction time. + f.vc.Advance(4 * time.Second) + if s := f.jumioStatus(ref); s != "DONE" { + t.Fatalf("status = %q, want DONE", s) + } + r = scanData(ref) + if r.Status != 200 || r.Body["status"] != "DONE" || r.Body["scanReference"] != ref { + t.Fatalf("done scan data -> %d %v, want the DONE retrieval shape", r.Status, r.Body) + } + ext, ok := r.Body["extractedData"].(map[string]any) + if !ok { + t.Fatalf("extractedData = %v, want an object", r.Body["extractedData"]) + } + docNum, _ := ext["documentNumber"].(string) + if ext["firstName"] != "JOHN" || ext["lastName"] != "DOE" || ext["dob"] != "1990-01-15" || + ext["expiry"] != "2030-06-20" || !strings.HasPrefix(docNum, "D1234567") { + t.Fatalf("extractedData = %v, want the synthetic document fields", ext) + } + if ext["country"] != "GBR" || ext["usState"] != "CA" || + ext["address"] != "123 MAIN ST, ANYTOWN, CA 90210" { + t.Fatalf("extractedData = %v, want the country echoed + the fixed locale fields", ext) + } + + // ===== FAILED scans answer data with a 409 repeating the reason ===== + failRef := f.jumioCreate(map[string]any{ + "merchantScanReference": "merchant-ref-002", "simulate_reject_reason": "DOCUMENT_EXPIRED", + }) + f.vc.Advance(4 * time.Second) + r = scanData(failRef) + if r.Status != 409 || !jumioNum(r.Body["httpStatus"], 409) { + t.Fatalf("failed scan data -> %d %v, want the 409 error envelope", r.Status, r.Body) + } + if r.Body["rejectionReason"] != "DOCUMENT_EXPIRED" { + t.Fatalf("failed scan data reason = %v, want DOCUMENT_EXPIRED repeated", r.Body["rejectionReason"]) + } + if _, has := r.Body["extractedData"]; has { + t.Fatalf("failed scan data carries extractedData = %v, want none", r.Body["extractedData"]) + } +} + +// TestJumioScanDelete: the deletion route — 200 with the deleted receipt, +// 404s for every later read, deletion before the terminal window skips the +// terminal side effects, and deletion after it still fires them. +func TestJumioScanDelete(t *testing.T) { + f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC()) + del := func(ref string) starlark.Response { + f.t.Helper() + return f.call("scans", "on_delete_scan", "DELETE", "/netverify/v2/scans/"+ref, + map[string]string{"scan_reference": ref}, nil, jumioAuth) + } + + // ===== delete removes the scan and later reads are 404s ===== + ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001"}) + r := del(ref) + if r.Status != 200 || r.Body["deleted"] != true || r.Body["scanReference"] != ref { + t.Fatalf("delete -> %d %v, want 200 {scanReference, deleted:true}", r.Status, r.Body) + } + if _, err := time.Parse(time.RFC3339, r.Body["timestamp"].(string)); err != nil { + t.Fatalf("delete timestamp = %v, want RFC3339", r.Body["timestamp"]) + } + for _, rt := range []struct { + handler, method, suffix string + }{ + {"on_get_scan", "GET", ""}, + {"on_delete_scan", "DELETE", ""}, + {"on_get_scan_data", "GET", "/data"}, + } { + if r := f.call("scans", rt.handler, rt.method, "/netverify/v2/scans/"+ref+rt.suffix, + map[string]string{"scan_reference": ref}, nil, jumioAuth); r.Status != 404 { + t.Fatalf("%s deleted scan -> %d, want 404", rt.method, r.Status) + } + } + + // ===== deleting after the terminal window still advances the lifecycle ===== + // Terminal side effects (webhook emission) fire before the scan disappears. + lateRef := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-002"}) + f.vc.Advance(4 * time.Second) + if r := del(lateRef); r.Status != 200 { + t.Fatalf("late delete -> %d: %v", r.Status, r.Body) + } + if r := f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+lateRef, + map[string]string{"scan_reference": lateRef}, nil, jumioAuth); r.Status != 404 { + t.Fatalf("late-deleted scan -> %d, want 404", r.Status) + } +} + +// TestJumioWebhookReceiverAndEvents: the X-Jumio-Webhook-Signature scheme in +// both directions — the inbound receiver MACs the exact request bytes, and +// the outbound scan.completed / scan.failed deliveries are signed over the +// exact delivered bytes and fire exactly once per terminal transition. +func TestJumioWebhookReceiverAndEvents(t *testing.T) { + f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC()) + delivered := f.captureWebhooks() + + // ===== a correctly MACed webhook body is accepted ===== + raw := `{"scanReference":"268435457-0000-4000-8000-100001000","status":"DONE"}` + mac := hmac.New(sha256.New, []byte(jumioWebhookSecret)) + mac.Write([]byte(raw)) + goodSig := hex.EncodeToString(mac.Sum(nil)) + post := func(sig, body string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if sig != "" { + headers["X-Jumio-Webhook-Signature"] = sig + } + resp, err := f.vms["hooks"].Call("on_webhook", starlark.Request{ + Method: "POST", Path: "/netverify/v2/webhooks", Host: f.host, Headers: headers, RawBody: body, + }) + if err != nil { + f.t.Fatalf("on_webhook: %v", err) + } + return resp + } + if r := post(goodSig, raw); r.Status != 200 || r.Body["received"] != true { + t.Fatalf("signed webhook -> %d %v, want 200 {received:true}", r.Status, r.Body) + } + + // ===== a tampered body, wrong MAC or missing header is a 401 ===== + if r := post(goodSig, raw+" "); r.Status != 401 { + t.Fatalf("tampered body -> %d, want 401", r.Status) + } else if !jumioNum(r.Body["httpStatus"], 401) { + t.Fatalf("tampered body envelope = %v, want the Jumio 401 shape", r.Body) + } + wrongMAC := goodSig[:8] + strings.Repeat("0", 8) + goodSig[16:] + if r := post(wrongMAC, raw); r.Status != 401 { + t.Fatalf("wrong MAC -> %d, want 401", r.Status) + } + if r := post("", "{}"); r.Status != 401 { + t.Fatalf("missing header -> %d, want 401", r.Status) + } + + // ===== the terminal transition emits exactly one signed scan.completed ===== + ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001"}) + f.vc.Advance(2 * time.Second) + if s := f.jumioStatus(ref); s != "PENDING" { + t.Fatalf("status at +2s = %q, want PENDING (no delivery yet)", s) + } + if n := len(delivered()); n != 0 { + t.Fatalf("%d deliveries before the terminal window, want 0", n) + } + f.vc.Advance(2 * time.Second) + if s := f.jumioStatus(ref); s != "DONE" { + t.Fatalf("status at +4s = %q, want DONE", s) + } + all := delivered() + if len(all) != 1 { + t.Fatalf("%d deliveries after completion, want exactly 1", len(all)) + } + jumioVerifyDelivery(t, all[0]) + eventType, payload := all[0].envelope(t) + if eventType != "scan.completed" || payload["scanReference"] != ref || payload["status"] != "DONE" { + t.Fatalf("webhook = %s %v, want scan.completed for the DONE scan", eventType, payload) + } + f.jumioStatus(ref) + if n := len(delivered()); n != 1 { + t.Fatalf("%d deliveries after a re-read, want still 1 (emit exactly once)", n) + } + + // ===== failed scans emit scan.failed carrying the rejection reason ===== + failRef := f.jumioCreate(map[string]any{ + "merchantScanReference": "merchant-ref-002", "simulate_reject_reason": "DOCUMENT_EXPIRED", + }) + f.vc.Advance(4 * time.Second) + if s := f.jumioStatus(failRef); s != "FAILED" { + t.Fatalf("simulate_reject_reason status = %q, want FAILED", s) + } + all = delivered() + if len(all) != 2 { + t.Fatalf("%d deliveries after the failed transition, want 2", len(all)) + } + jumioVerifyDelivery(t, all[1]) + eventType, payload = all[1].envelope(t) + if eventType != "scan.failed" || payload["scanReference"] != failRef || + payload["rejectionReason"] != "DOCUMENT_EXPIRED" { + t.Fatalf("webhook = %s %v, want scan.failed with the reject reason", eventType, payload) + } + + // ===== a delete-driven terminal transition also emits, then nothing more ===== + // Deleting a not-yet-read completed scan advances it first. + unreadRef := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-003"}) + f.vc.Advance(4 * time.Second) + if r := f.call("scans", "on_delete_scan", "DELETE", "/netverify/v2/scans/"+unreadRef, + map[string]string{"scan_reference": unreadRef}, nil, jumioAuth); r.Status != 200 { + t.Fatalf("delete unread scan -> %d: %v", r.Status, r.Body) + } + all = delivered() + if len(all) != 3 { + t.Fatalf("%d deliveries after the delete-driven transition, want 3", len(all)) + } + jumioVerifyDelivery(t, all[2]) + f.jumioStatus(ref) + if n := len(delivered()); n != 3 { + t.Fatalf("%d deliveries after re-reads, want still 3", n) + } +} diff --git a/adapters/linkedin-style/scripts/comments.star b/adapters/linkedin-style/scripts/comments.star index 2d9a8033..c10e0a1e 100644 --- a/adapters/linkedin-style/scripts/comments.star +++ b/adapters/linkedin-style/scripts/comments.star @@ -39,7 +39,13 @@ def on_list_comments(req): start = _to_int(req["query"].get("start", "")) links = [] if next_cursor != None: - links.append({"rel": "next", "href": "/rest/comments?start=" + next_cursor}) + # Real LinkedIn next links carry the whole query; a bare start link + # would 400 on the q=author check as soon as it is followed. + count = _to_int(req["query"].get("count", "")) + links.append({ + "rel": "next", + "href": "/rest/comments?q=author&count=" + str(count) + "&start=" + next_cursor, + }) return respond(200, { "elements": page, @@ -64,6 +70,14 @@ def on_post_comment(req): if actor == "urn:li:person:me": actor = "urn:li:person:" + member["sub"] + # A comment is authored by the caller, like ugcPosts' author check. + if actor != "urn:li:person:" + member["sub"]: + return respond(403, { + "status": 403, + "code": "FIELDS_DATA_VALIDATION_EXCEPTION", + "message": "actor " + actor + " is not the authenticated member", + }) + # Verify the target post exists. pc = store_collection("posts") post = pc.get(object_urn) @@ -79,7 +93,8 @@ def on_post_comment(req): "actor": actor, "object": object_urn, "text": text, - "ts_ms": 1700000000000, + # Engine clock, not a frozen stamp: createdOn tracks when it posted. + "ts_ms": clock.now_unix() * 1000, }) return respond(201, {"id": comment_urn}) diff --git a/adapters/linkedin-style/scripts/oauth.star b/adapters/linkedin-style/scripts/oauth.star index 237aaac4..e7819072 100644 --- a/adapters/linkedin-style/scripts/oauth.star +++ b/adapters/linkedin-style/scripts/oauth.star @@ -119,13 +119,15 @@ def on_access_token(req): if code_doc == None: return respond(400, {"error": "invalid_grant", "error_description": "invalid/used code"}) - cc.delete(code) - want_cid = code_doc.get("client_id", "") want_uri = code_doc.get("redirect_uri", "") if client_id != want_cid or redirect_uri != want_uri or client_secret == "": + # Consume only on the matched path: a wrong client must not burn the + # code for the right one. return respond(400, {"error": "invalid_client", "error_description": "client mismatch"}) + cc.delete(code) + return respond(200, _issue_tokens(_mint_member())) def _contains(s, substr): diff --git a/adapters/linkedin-style/scripts/posts.star b/adapters/linkedin-style/scripts/posts.star index c32eec7a..0beb1c44 100644 --- a/adapters/linkedin-style/scripts/posts.star +++ b/adapters/linkedin-style/scripts/posts.star @@ -67,9 +67,11 @@ def on_resolve_post(req): return respond(404, {"status": 404, "message": "post not found"}) seq = doc.get("seq", 0) + # The post's own author, not the caller: any member may resolve any post. + # Collections round-trip ints as floats; the URN needs bare digits. return respond(200, { - "id": "urn:li:share:" + str(seq), - "author": "urn:li:person:" + member["sub"], + "id": "urn:li:share:" + str(int(seq)), + "author": doc.get("author", ""), }) def _extract_text(body): diff --git a/adapters/linkedin_style_test.go b/adapters/linkedin_style_test.go new file mode 100644 index 00000000..9a262d45 --- /dev/null +++ b/adapters/linkedin_style_test.go @@ -0,0 +1,936 @@ +package adapters + +import ( + "net/url" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// These tests drive the linkedin-style adapter scripts directly (lib.star +// preloaded) over a shared store and a VIRTUAL clock: the Arctic-style OAuth2 +// authorization-code flow with body-param client credentials, single-use +// refresh-token rotation, the Bearer gate (LinkedIn's flat {status, code, +// message} service error envelope, 60-day clock-derived expiry), the +// token-bound /v2/userinfo profile, ugcPosts publishing with its author +// authorization and KV-armed rate-limit injection, post resolution (ugcPost +// -> share URN), the comments ingest/reply pair with q=author scoping and +// count/start paging whose next link round-trips the query, and the +// memberCreatorPostAnalytics metric buckets. + +// One OAuth client for the whole suite; the adapter accepts any values. +const ( + liRedirectURI = "http://localhost:3000/callback" + liState = "vm-suite-state" + liClientID = "li-vm-client-id" + liClientSecret = "li-vm-client-secret" + liShareContent = "com.linkedin.ugc.ShareContent" + liMetricTypeKey = "com.linkedin.adsexternalapi.memberanalytics.v1.CreatorPostAnalyticsMetricTypeV1" +) + +// liFixture is one shared store + virtual clock with a loaded VM per handler +// script (oauth, userinfo, posts, comments and analytics each get their own +// VM, but they observe the same collections/kv state, like the engine). +type liFixture struct { + t *testing.T + vc *clock.Clock + kv *kv.KV + vms map[string]*starlark.VM +} + +func newLinkedinFixture(t *testing.T, start time.Time) *liFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "linkedin-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &liFixture{t: t, vc: vc, kv: kvStore, vms: map[string]*starlark.VM{ + "oauth": load("oauth.star"), "userinfo": load("userinfo.star"), + "posts": load("posts.star"), "comments": load("comments.star"), + "analytics": load("analytics.star"), + }} +} + +// call invokes handler on the named script VM; auth is the full +// Authorization header value ("" = header absent). +func (f *liFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: "api.linkedin.test", + Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// liNum compares a response number against want whether it arrives as an +// int (handler literal) or a float (round-tripped through a collection). +func liNum(got any, want int64) bool { + switch n := got.(type) { + case int64: + return n == want + case float64: + return n == float64(want) + } + return false +} + +// liInt coerces a response number (int or round-tripped float) to int64. +func liInt(got any) (int64, bool) { + switch n := got.(type) { + case int64: + return n, true + case float64: + return int64(n), true + } + return 0, false +} + +// liAuthorize runs the authorization redirect against redirectURI and +// returns the single-use code it mints. +func (f *liFixture) liAuthorize(redirectURI string) string { + f.t.Helper() + r := f.call("oauth", "on_authorize", "GET", "/oauth/v2/authorization", nil, map[string]string{ + "client_id": liClientID, "redirect_uri": redirectURI, "state": liState, + "response_type": "code", "scope": "openid profile w_member_social email", + }, nil, "") + if r.Status != 302 { + f.t.Fatalf("authorize -> %d: %v", r.Status, r.Body) + } + loc, err := url.Parse(r.Headers["Location"]) + if err != nil { + f.t.Fatalf("authorize Location %q: %v", r.Headers["Location"], err) + } + code := loc.Query().Get("code") + if code == "" { + f.t.Fatalf("authorize Location %q carries no code", r.Headers["Location"]) + } + return code +} + +// liExchangeCode trades an authorization code (body-param client creds, the +// Arctic convention — no HTTP Basic Auth). +func (f *liFixture) liExchangeCode(code, clientID, secret, redirectURI string) starlark.Response { + f.t.Helper() + body := map[string]any{"grant_type": "authorization_code", "code": code} + if clientID != "" { + body["client_id"] = clientID + } + if secret != "" { + body["client_secret"] = secret + } + if redirectURI != "" { + body["redirect_uri"] = redirectURI + } + return f.call("oauth", "on_access_token", "POST", "/oauth/v2/accessToken", nil, nil, body, "") +} + +// liRefresh presents a refresh token for rotation. +func (f *liFixture) liRefresh(token string, withCreds bool) starlark.Response { + f.t.Helper() + body := map[string]any{"grant_type": "refresh_token", "refresh_token": token} + if withCreds { + body["client_id"] = liClientID + body["client_secret"] = liClientSecret + } + return f.call("oauth", "on_access_token", "POST", "/oauth/v2/accessToken", nil, nil, body, "") +} + +// liUserinfo resolves a bearer to its member document. +func (f *liFixture) liUserinfo(token string) starlark.Response { + f.t.Helper() + return f.call("userinfo", "on_userinfo", "GET", "/v2/userinfo", nil, nil, nil, "Bearer "+token) +} + +// liMint runs the authorization-code flow end to end and returns the minted +// (token, member sub). +func (f *liFixture) liMint() (string, string) { + f.t.Helper() + code := f.liAuthorize(liRedirectURI) + r := f.liExchangeCode(code, liClientID, liClientSecret, liRedirectURI) + if r.Status != 200 { + f.t.Fatalf("accessToken -> %d: %v", r.Status, r.Body) + } + token, _ := r.Body["access_token"].(string) + if token == "" { + f.t.Fatalf("accessToken response = %v, want an access_token", r.Body) + } + u := f.liUserinfo(token) + if u.Status != 200 { + f.t.Fatalf("userinfo after exchange -> %d: %v", u.Status, u.Body) + } + sub, _ := u.Body["sub"].(string) + return token, sub +} + +// liPublish posts ugcPosts content as author; author may deliberately +// mismatch the token's member. +func (f *liFixture) liPublish(token, author, text string) starlark.Response { + f.t.Helper() + return f.call("posts", "on_ugc_posts", "POST", "/v2/ugcPosts", nil, nil, map[string]any{ + "author": author, + "lifecycleState": "PUBLISHED", + "specificContent": map[string]any{ + liShareContent: map[string]any{ + "shareCommentary": map[string]any{"text": text}, + "shareMediaCategory": "NONE", + }, + }, + "visibility": map[string]any{"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}, + }, "Bearer "+token) +} + +// liResolvePost GETs /rest/posts/{urlencoded urn}. +func (f *liFixture) liResolvePost(token, urn string) starlark.Response { + f.t.Helper() + return f.call("posts", "on_resolve_post", "GET", "/rest/posts/"+url.PathEscape(urn), + map[string]string{"urn": urn}, nil, nil, "Bearer "+token) +} + +// liComment posts a comment on objectUrn; actor may deliberately mismatch. +func (f *liFixture) liComment(token, actor, objectUrn, text string) starlark.Response { + f.t.Helper() + return f.call("comments", "on_post_comment", "POST", "/rest/comments", nil, nil, map[string]any{ + "actor": actor, + "object": objectUrn, + "message": map[string]any{"text": text}, + }, "Bearer "+token) +} + +// liIngest lists the token member's comments (q=author by default). +func (f *liFixture) liIngest(token string, query map[string]string) starlark.Response { + f.t.Helper() + if query == nil { + query = map[string]string{"q": "author"} + } + return f.call("comments", "on_list_comments", "GET", "/rest/comments", nil, query, nil, "Bearer "+token) +} + +// liElements returns the response's elements array. +func liElements(t *testing.T, r starlark.Response) []any { + t.Helper() + elements, ok := r.Body["elements"].([]any) + if !ok { + t.Fatalf("response %d elements = %v, want an array", r.Status, r.Body["elements"]) + } + return elements +} + +// liPaging returns the response's paging block. +func liPaging(t *testing.T, r starlark.Response) map[string]any { + t.Helper() + paging, ok := r.Body["paging"].(map[string]any) + if !ok { + t.Fatalf("response %d carries no paging block: %v", r.Status, r.Body) + } + return paging +} + +// TestLinkedinOAuthAuthorizationCodeFlow: the authorize redirect demands its +// three params and redirects with a fresh code + state echo, the exchange is +// grant-type checked with body-param client credentials, codes are +// single-use, and client mismatches are rejected without burning the code. +func TestLinkedinOAuthAuthorizationCodeFlow(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + authorize := func(query map[string]string) starlark.Response { + return f.call("oauth", "on_authorize", "GET", "/oauth/v2/authorization", nil, query, nil, "") + } + + // ===== authorize without redirect_uri, state or client_id is invalid_request ===== + // Each missing param alone is a 400. + for name, query := range map[string]map[string]string{ + "no redirect_uri": {"client_id": liClientID, "state": liState}, + "no state": {"client_id": liClientID, "redirect_uri": liRedirectURI}, + "no client_id": {"state": liState, "redirect_uri": liRedirectURI}, + } { + if r := authorize(query); r.Status != 400 || r.Body["error"] != "invalid_request" { + t.Fatalf("authorize %s -> %d %v, want 400 invalid_request", name, r.Status, r.Body) + } + } + + // ===== authorize redirects back with a fresh code and the state echoed ===== + code := f.liAuthorize(liRedirectURI) + if !strings.HasPrefix(code, "mock_code_") { + t.Fatalf("authorize code = %q, want a mock_code_* mint", code) + } + r := authorize(map[string]string{ + "client_id": liClientID, "redirect_uri": liRedirectURI, "state": liState, + }) + loc, err := url.Parse(r.Headers["Location"]) + if err != nil { + t.Fatalf("authorize Location %q: %v", r.Headers["Location"], err) + } + if loc.Query().Get("state") != liState { + t.Fatalf("authorize Location %q, want the caller's state echoed", r.Headers["Location"]) + } + + // ===== a redirect_uri that already carries a query is joined with & ===== + r = authorize(map[string]string{ + "client_id": liClientID, "redirect_uri": "http://localhost:3000/cb?from=vm", "state": liState, + }) + if loc := r.Headers["Location"]; !strings.Contains(loc, "cb?from=vm&code=") { + t.Fatalf("authorize Location = %q, want the existing query joined with &", loc) + } + + // ===== the exchange demands grant_type=authorization_code ===== + // Wrong grant types are 400 unsupported_grant_type (RFC 6749 shape). + if r := f.call("oauth", "on_access_token", "POST", "/oauth/v2/accessToken", nil, nil, + map[string]any{"grant_type": "client_credentials", "code": code}, ""); r.Status != 400 || + r.Body["error"] != "unsupported_grant_type" { + t.Fatalf("client_credentials grant -> %d %v, want 400 unsupported_grant_type", r.Status, r.Body) + } + + // ===== an unknown code is 400 invalid_grant ===== + if r := f.liExchangeCode("mock_code_nope", liClientID, liClientSecret, liRedirectURI); r.Status != 400 || + r.Body["error"] != "invalid_grant" { + t.Fatalf("unknown code -> %d %v, want 400 invalid_grant", r.Status, r.Body) + } + + // ===== a good exchange mints a 60-day token pair for a fresh member ===== + // Client creds ride the body (Arctic), never HTTP Basic Auth. + fresh := f.liAuthorize(liRedirectURI) + r = f.liExchangeCode(fresh, liClientID, liClientSecret, liRedirectURI) + if r.Status != 200 { + t.Fatalf("access_token -> %d: %v", r.Status, r.Body) + } + access, _ := r.Body["access_token"].(string) + refresh, _ := r.Body["refresh_token"].(string) + if !strings.HasPrefix(access, "mock_access_") || !strings.HasPrefix(refresh, "mock_refresh_") { + t.Fatalf("exchange minted %q / %q, want mock_access_* / mock_refresh_*", access, refresh) + } + if !liNum(r.Body["expires_in"], 5184000) { + t.Fatalf("expires_in = %v (%T), want 5184000 (60 days)", r.Body["expires_in"], r.Body["expires_in"]) + } + if r.Body["scope"] != "openid profile w_member_social email" { + t.Fatalf("scope = %v, want the member/social scope set", r.Body["scope"]) + } + + // ===== the code is single-use: a replay is invalid_grant ===== + if r := f.liExchangeCode(fresh, liClientID, liClientSecret, liRedirectURI); r.Status != 400 || + r.Body["error"] != "invalid_grant" { + t.Fatalf("code replay -> %d %v, want 400 invalid_grant (single-use)", r.Status, r.Body) + } + + // ===== client mismatches are 400 invalid_client ===== + // client_id, redirect_uri and client_secret must all match the authorize; + // each case gets a fresh code (codes are single-use). + for _, tc := range []struct{ name, clientID, secret, redirectURI string }{ + {"wrong client_id", "attacker-app", liClientSecret, liRedirectURI}, + {"wrong redirect_uri", liClientID, liClientSecret, "http://evil.test/cb"}, + {"missing secret", liClientID, "", liRedirectURI}, + } { + r := f.liExchangeCode(f.liAuthorize(liRedirectURI), tc.clientID, tc.secret, tc.redirectURI) + if r.Status != 400 || r.Body["error"] != "invalid_client" { + t.Fatalf("exchange %s -> %d %v, want 400 invalid_client", tc.name, r.Status, r.Body) + } + } + + // ===== a mismatched attempt must not burn the code ===== + // The wrong client's failed exchange leaves the code redeemable by the + // right one (the delete happens on the matched path only). + code3 := f.liAuthorize(liRedirectURI) + f.liExchangeCode(code3, "attacker-app", liClientSecret, liRedirectURI) // 400, above + if r := f.liExchangeCode(code3, liClientID, liClientSecret, liRedirectURI); r.Status != 200 { + t.Fatalf("right client after a mismatched attempt -> %d %v, want 200 (code not burned)", r.Status, r.Body) + } + + // ===== a second flow mints a distinct member ===== + _, sub1 := f.liMint() + _, sub2 := f.liMint() + if sub1 == "" || sub2 == "" || sub1 == sub2 { + t.Fatalf("flows minted subs %q / %q, want distinct non-empty members", sub1, sub2) + } +} + +// TestLinkedinRefreshTokenRotation: the refresh grant demands body-param +// client creds, rotates the pair single-use while keeping the same member, +// leaves the old access token valid until its own expiry, and chains. +func TestLinkedinRefreshTokenRotation(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + code := f.liAuthorize(liRedirectURI) + r := f.liExchangeCode(code, liClientID, liClientSecret, liRedirectURI) + if r.Status != 200 { + t.Fatalf("access_token -> %d: %v", r.Status, r.Body) + } + access1, _ := r.Body["access_token"].(string) + refresh1, _ := r.Body["refresh_token"].(string) + sub1, _ := f.liUserinfo(access1).Body["sub"].(string) + + // ===== the refresh grant demands client creds ===== + if r := f.liRefresh(refresh1, false); r.Status != 400 || r.Body["error"] != "invalid_client" { + t.Fatalf("refresh without client creds -> %d %v, want 400 invalid_client", r.Status, r.Body) + } + + // ===== an unknown refresh token is invalid_grant ===== + if r := f.liRefresh("mock_refresh_nope", true); r.Status != 400 || r.Body["error"] != "invalid_grant" { + t.Fatalf("unknown refresh token -> %d %v, want 400 invalid_grant", r.Status, r.Body) + } + + // ===== refresh rotates the pair and keeps the member ===== + r = f.liRefresh(refresh1, true) + if r.Status != 200 { + t.Fatalf("refresh -> %d: %v", r.Status, r.Body) + } + access2, _ := r.Body["access_token"].(string) + refresh2, _ := r.Body["refresh_token"].(string) + if access2 == "" || access2 == access1 || refresh2 == "" || refresh2 == refresh1 { + t.Fatalf("refresh minted %q / %q, want a fresh pair distinct from %q / %q", access2, refresh2, access1, refresh1) + } + if !liNum(r.Body["expires_in"], 5184000) { + t.Fatalf("refresh expires_in = %v, want 5184000", r.Body["expires_in"]) + } + u := f.liUserinfo(access2) + if u.Status != 200 || u.Body["sub"] != sub1 { + t.Fatalf("userinfo with rotated token -> %d %v, want the same member %q", u.Status, u.Body, sub1) + } + // Rotation consumes only the refresh token: the old access token stays + // valid until its own expiry, like real LinkedIn. + if u := f.liUserinfo(access1); u.Status != 200 { + t.Fatalf("original access token after refresh -> %d, want still valid", u.Status) + } + + // ===== the presented refresh token is single-use ===== + if r := f.liRefresh(refresh1, true); r.Status != 400 || r.Body["error"] != "invalid_grant" { + t.Fatalf("refresh replay -> %d %v, want 400 invalid_grant (single-use rotation)", r.Status, r.Body) + } + + // ===== rotation chains: the new refresh token refreshes again ===== + if r := f.liRefresh(refresh2, true); r.Status != 200 { + t.Fatalf("second-generation refresh -> %d %v, want 200", r.Status, r.Body) + } +} + +// TestLinkedinBearerGate: every API route validates the Bearer against the +// tokens collection — missing, wrong-scheme, unknown and expired tokens all +// answer 401 in LinkedIn's service error envelope {status, code: AUTHORIZED}. +func TestLinkedinBearerGate(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + token, _ := f.liMint() + + // ===== a missing bearer is 401 in the service error envelope ===== + // The envelope is flat {status, code, message} (no nested "error"). + r := f.liUserinfo("") + if r.Status != 401 { + t.Fatalf("no bearer -> %d, want 401", r.Status) + } + if !liNum(r.Body["status"], 401) || r.Body["code"] != "AUTHORIZED" || r.Body["message"] == nil { + t.Fatalf("401 envelope = %v, want status 401 code AUTHORIZED with a message", r.Body) + } + if _, nested := r.Body["error"]; nested { + t.Fatalf("401 envelope nests an error object = %v, want the flat shape", r.Body) + } + + // ===== wrong schemes and unknown bearers answer the same 401 ===== + for _, auth := range []string{ + "Token " + token, // wrong scheme + token, // bare token, no scheme + "Bearer totally-fake-token", + } { + r := f.call("userinfo", "on_userinfo", "GET", "/v2/userinfo", nil, nil, nil, auth) + if r.Status != 401 || r.Body["code"] != "AUTHORIZED" { + t.Fatalf("auth %q -> %d %v, want 401 AUTHORIZED", auth, r.Status, r.Body) + } + } + + // ===== every API route enforces the same gate ===== + // All six API handlers reject a missing and an unknown bearer. The 401 + // message string varies by route (userinfo's longer text vs "token") — + // asserted as-is. + routes := []struct { + group, handler, method, path string + params, query map[string]string + body map[string]any + }{ + {"userinfo", "on_userinfo", "GET", "/v2/userinfo", nil, nil, nil}, + {"posts", "on_ugc_posts", "POST", "/v2/ugcPosts", nil, nil, nil}, + {"posts", "on_resolve_post", "GET", "/rest/posts/urn:li:ugcPost:1", + map[string]string{"urn": "urn:li:ugcPost:1"}, nil, nil}, + {"comments", "on_list_comments", "GET", "/rest/comments", nil, map[string]string{"q": "author"}, nil}, + {"comments", "on_post_comment", "POST", "/rest/comments", nil, nil, nil}, + {"analytics", "on_analytics", "GET", "/rest/memberCreatorPostAnalytics", + nil, map[string]string{"q": "entity", "entity": "(ugcPost:urn:li:ugcPost:1)", "queryType": "REACTION"}, nil}, + } + for _, rt := range routes { + for _, auth := range []string{"", "Bearer unknown-token"} { + r := f.call(rt.group, rt.handler, rt.method, rt.path, rt.params, rt.query, rt.body, auth) + if r.Status != 401 { + t.Fatalf("%s (auth %q) -> %d, want 401", rt.path, auth, r.Status) + } + if r.Body["code"] != "AUTHORIZED" || !liNum(r.Body["status"], 401) { + t.Fatalf("%s 401 envelope = %v, want the AUTHORIZED shape", rt.path, r.Body) + } + } + } + + // ===== a bearer dies at its clock-derived 60-day expiry ===== + // expires_at is minted from the engine clock; the clock is virtual. + f.vc.Advance(61 * 24 * time.Hour) + if r := f.liUserinfo(token); r.Status != 401 || r.Body["code"] != "AUTHORIZED" { + t.Fatalf("expired bearer -> %d %v, want 401 AUTHORIZED", r.Status, r.Body) + } +} + +// TestLinkedinUserinfoAndPublish: /v2/userinfo answers the member bound to +// the bearer, ugcPosts authorizes the author against that member, mints a +// urn:li:ugcPost echoed in the x-linkedin-id header, and /rest/posts/{urn} +// resolves it to a share URN carrying the post's own author. +func TestLinkedinUserinfoAndPublish(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + token, sub := f.liMint() + person := "urn:li:person:" + sub + + // ===== userinfo returns the OAuth member profile ===== + u := f.liUserinfo(token) + if u.Status != 200 { + t.Fatalf("userinfo -> %d: %v", u.Status, u.Body) + } + if u.Body["sub"] != sub || !strings.HasPrefix(sub, "mock-member-") { + t.Fatalf("userinfo sub = %v, want the minted mock-member-*", u.Body["sub"]) + } + name, _ := u.Body["name"].(string) + email, _ := u.Body["email"].(string) + picture, _ := u.Body["picture"].(string) + if !strings.HasPrefix(name, "Mock Member ") || !strings.Contains(email, "@example.test") || + !strings.Contains(picture, sub) { + t.Fatalf("userinfo profile = %v, want the minted name/email/picture", u.Body) + } + + // ===== publishing as anyone but the token's member is a 403 ===== + for _, author := range []string{"urn:li:person:someone-else", ""} { + r := f.liPublish(token, author, "forged") + if r.Status != 403 { + t.Fatalf("publish as %q -> %d, want 403", author, r.Status) + } + if r.Body["code"] != "FIELDS_DATA_VALIDATION_EXCEPTION" || !liNum(r.Body["status"], 403) { + t.Fatalf("publish 403 envelope = %v, want FIELDS_DATA_VALIDATION_EXCEPTION", r.Body) + } + } + + // ===== a good publish mints a ugcPost urn echoed in x-linkedin-id ===== + r := f.liPublish(token, person, "hello from the VM suite") + if r.Status != 201 { + t.Fatalf("publish -> %d: %v", r.Status, r.Body) + } + urn, _ := r.Body["id"].(string) + if !strings.HasPrefix(urn, "urn:li:ugcPost:") { + t.Fatalf("publish id = %v, want a urn:li:ugcPost:* mint", r.Body["id"]) + } + if r.Headers["x-linkedin-id"] != urn { + t.Fatalf("x-linkedin-id = %q, want the created urn %q", r.Headers["x-linkedin-id"], urn) + } + + // ===== the post resolves to a share urn carrying its own author ===== + // Any member may resolve any post; the author is the post's, not the + // caller's. + seq := strings.TrimPrefix(urn, "urn:li:ugcPost:") + res := f.liResolvePost(token, urn) + if res.Status != 200 { + t.Fatalf("resolve -> %d: %v", res.Status, res.Body) + } + if res.Body["id"] != "urn:li:share:"+seq || res.Body["author"] != person { + t.Fatalf("resolve = %v, want share urn for seq %s authored by %s", res.Body, seq, person) + } + token2, _ := f.liMint() + if res := f.liResolvePost(token2, urn); res.Status != 200 || res.Body["author"] != person { + t.Fatalf("resolve by a second member = %v, want the post's own author %s", res.Body, person) + } + + // ===== resolving an unknown urn is a 404 ===== + if r := f.liResolvePost(token, "urn:li:ugcPost:999999"); r.Status != 404 || + !liNum(r.Body["status"], 404) || r.Body["message"] != "post not found" { + t.Fatalf("resolve unknown urn -> %d %v, want 404 post not found", r.Status, r.Body) + } +} + +// TestLinkedinPublishRateLimit: the publish path counts posts per member URN +// and, once the linkedin/fail_after KV knob is armed, answers 429 +// REQUEST_LIMIT_EXCEEDED past the threshold without consuming a post seq. +func TestLinkedinPublishRateLimit(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + token, sub := f.liMint() + person := "urn:li:person:" + sub + + seqOf := func(r starlark.Response) int64 { + f.t.Helper() + urn, _ := r.Body["id"].(string) + n, err := strconv.ParseInt(strings.TrimPrefix(urn, "urn:li:ugcPost:"), 10, 64) + if err != nil { + f.t.Fatalf("post urn %q carries no numeric seq: %v", urn, err) + } + return n + } + + // ===== unconfigured, publishing is unthrottled ===== + lastSeq := int64(0) + for i := 0; i < 5; i++ { + r := f.liPublish(token, person, "burst") + if r.Status != 201 { + t.Fatalf("publish %d with no fail_after -> %d %v, want 201", i+1, r.Status, r.Body) + } + lastSeq = seqOf(r) + } + if lastSeq < 5 { + t.Fatalf("five publishes advanced the seq to %d, want >= 5", lastSeq) + } + + // ===== arming fail_after injects 429 REQUEST_LIMIT_EXCEEDED ===== + if err := f.kv.Set("linkedin", "fail_after", "1"); err != nil { + t.Fatalf("arm fail_after: %v", err) + } + for i := 0; i < 2; i++ { + r := f.liPublish(token, person, "over the limit") + if r.Status != 429 { + t.Fatalf("publish past fail_after=1 (#%d) -> %d %v, want 429", i+1, r.Status, r.Body) + } + if r.Body["code"] != "REQUEST_LIMIT_EXCEEDED" || !liNum(r.Body["status"], 429) { + t.Fatalf("429 envelope = %v, want REQUEST_LIMIT_EXCEEDED", r.Body) + } + } + + // ===== the limit is per member ===== + token2, sub2 := f.liMint() + r2 := f.liPublish(token2, "urn:li:person:"+sub2, "unaffected") + if r2.Status != 201 { + t.Fatalf("second member publish while the first is throttled -> %d %v, want 201", r2.Status, r2.Body) + } + bSeq := seqOf(r2) + + // ===== a throttled attempt creates no post ===== + // The 429 path returns before post_seq is consumed, so the next accepted + // post (after disarming) takes the very next seq after B's. + if err := f.kv.Delete("linkedin", "fail_after"); err != nil { + t.Fatalf("disarm fail_after: %v", err) + } + next := seqOf(f.liPublish(token, person, "back online")) + if next != bSeq+1 { + t.Fatalf("first post after the 429s has seq %d, want %d (no seqs burned)", next, bSeq+1) + } + if r := f.liResolvePost(token, "urn:li:ugcPost:"+strconv.FormatInt(next+1, 10)); r.Status != 404 { + t.Fatalf("resolve the seq after the last created -> %d, want 404 (throttled posts were never stored)", r.Status) + } +} + +// TestLinkedinCommentsIngestReply: POST /rest/comments resolves +// urn:li:person:me to the authenticated member (and refuses any other actor), +// 404s unknown objects, and GET /rest/comments?q=author lists only the token +// member's comments with clock-stamped createdOn and count/start paging whose +// next link round-trips the query. +func TestLinkedinCommentsIngestReply(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + tokenA, subA := f.liMint() + personA := "urn:li:person:" + subA + tokenB, subB := f.liMint() + personB := "urn:li:person:" + subB + + post, _ := f.liPublish(tokenA, personA, "the commented post").Body["id"].(string) + if post == "" { + t.Fatal("setup publish failed") + } + + // ===== q must be author ===== + for _, q := range []string{"", "reader"} { + query := map[string]string{} + if q != "" { + query["q"] = q + } + if r := f.liIngest(tokenA, query); r.Status != 400 || r.Body["message"] != "unsupported query" { + t.Fatalf("q=%q -> %d %v, want 400 unsupported query", q, r.Status, r.Body) + } + } + + // ===== reply resolves urn:li:person:me to the authenticated member ===== + // Three comments for A an hour apart (virtual clock); the ids round-trip + // through ingest below. + var idsA []string + for i, text := range []string{"first", "second", "third"} { + if i > 0 { + f.vc.Advance(time.Hour) + } + r := f.liComment(tokenA, "urn:li:person:me", post, text) + if r.Status != 201 { + t.Fatalf("comment %d -> %d: %v", i+1, r.Status, r.Body) + } + id, _ := r.Body["id"].(string) + if !strings.HasPrefix(id, "urn:li:comment:") { + t.Fatalf("comment id = %v, want a urn:li:comment:* mint", r.Body["id"]) + } + idsA = append(idsA, id) + } + rB := f.liComment(tokenB, "urn:li:person:me", post, "from member B") + if rB.Status != 201 { + t.Fatalf("member B comment -> %d: %v", rB.Status, rB.Body) + } + idB, _ := rB.Body["id"].(string) + + // ===== commenting as anyone but the caller is a 403 ===== + for _, actor := range []string{personB, ""} { + r := f.liComment(tokenA, actor, post, "forged") + if r.Status != 403 || r.Body["code"] != "FIELDS_DATA_VALIDATION_EXCEPTION" { + t.Fatalf("comment as %q -> %d %v, want 403 FIELDS_DATA_VALIDATION_EXCEPTION", actor, r.Status, r.Body) + } + } + + // ===== replying to an unknown object is a 404 ===== + if r := f.liComment(tokenA, "urn:li:person:me", "urn:li:ugcPost:999999", "orphan"); r.Status != 404 || + r.Body["message"] != "object not found" { + t.Fatalf("comment on unknown object -> %d %v, want 404 object not found", r.Status, r.Body) + } + + // ===== ingest lists only the token member's comments ===== + r := f.liIngest(tokenA, nil) + if r.Status != 200 { + t.Fatalf("ingest -> %d: %v", r.Status, r.Body) + } + elements := liElements(t, r) + if len(elements) != 3 { + t.Fatalf("member A ingest has %d elements, want its 3", len(elements)) + } + for i, item := range elements { + c := item.(map[string]any) + if c["id"] != idsA[i] { + t.Fatalf("elements[%d].id = %v, want %s (insertion order)", i, c["id"], idsA[i]) + } + if c["actor"] != personA || c["object"] != post { + t.Fatalf("elements[%d] actor/object = %v / %v, want %s / %s", i, c["actor"], c["object"], personA, post) + } + msg, _ := c["message"].(map[string]any) + if msg["text"] == "" { + t.Fatalf("elements[%d].message.text = %v, want non-empty", i, msg["text"]) + } + if _, has := c["lastModified"]; !has { + t.Fatalf("elements[%d] carries no lastModified: %v", i, c) + } + } + + // ===== member B's comment resolved me and lists only under B ===== + elementsB := liElements(t, f.liIngest(tokenB, nil)) + if len(elementsB) != 1 || elementsB[0].(map[string]any)["id"] != idB { + t.Fatalf("member B ingest = %v, want only its comment %s", elementsB, idB) + } + if got := elementsB[0].(map[string]any)["actor"]; got != personB { + t.Fatalf("member B comment actor = %v, want %s (me resolved to the member)", got, personB) + } + + // ===== createdOn is clock-stamped and monotonic ===== + // The stamps advance with the virtual clock (one hour per comment), not a + // frozen constant. + stamps := make([]int64, 0, len(elements)) + for i, item := range elements { + lm := item.(map[string]any)["lastModified"].(map[string]any) + n, ok := liInt(lm["createdOn"]) + if !ok { + t.Fatalf("createdOn = %v (%T), want an epoch-ms number", lm["createdOn"], lm["createdOn"]) + } + stamps = append(stamps, n) + if stamps[0] < 1_750_000_000_000 { + t.Fatalf("createdOn[%d] = %d, want at least the virtual clock start in ms", i, n) + } + } + for i := 1; i < len(stamps); i++ { + if stamps[i] <= stamps[i-1] { + t.Fatalf("createdOn not monotonic: %v", stamps) + } + } + if stamps[2]-stamps[0] < 2*3600*1000 { + t.Fatalf("createdOn spread = %d ms, want at least the two 1h advances", stamps[2]-stamps[0]) + } + + // ===== count pages with a next link that round-trips the query ===== + r = f.liIngest(tokenA, map[string]string{"q": "author", "count": "2"}) + page1 := liElements(t, r) + if len(page1) != 2 || page1[0].(map[string]any)["id"] != idsA[0] { + t.Fatalf("page 1 = %v, want the first two comments", page1) + } + paging := liPaging(t, r) + if !liNum(paging["count"], 2) || !liNum(paging["start"], 0) { + t.Fatalf("page 1 paging = %v, want count 2 start 0", paging) + } + links, _ := paging["links"].([]any) + if len(links) != 1 { + t.Fatalf("page 1 links = %v, want exactly the next link", paging["links"]) + } + link := links[0].(map[string]any) + wantHref := "/rest/comments?q=author&count=2&start=2" + if link["rel"] != "next" || link["href"] != wantHref { + t.Fatalf("next link = %v, want %q (the query survives the hop)", link, wantHref) + } + // Following the advertised href verbatim must not 400 on the q check. + r = f.liIngest(tokenA, map[string]string{"q": "author", "count": "2", "start": "2"}) + page2 := liElements(t, r) + if len(page2) != 1 || page2[0].(map[string]any)["id"] != idsA[2] { + t.Fatalf("page 2 = %v, want the last comment", page2) + } + paging = liPaging(t, r) + if !liNum(paging["count"], 1) || !liNum(paging["start"], 2) { + t.Fatalf("page 2 paging = %v, want count 1 start 2", paging) + } + if links, _ := paging["links"].([]any); len(links) != 0 { + t.Fatalf("final page links = %v, want none", paging["links"]) + } + + // ===== without count the whole list returns unpaged ===== + r = f.liIngest(tokenA, nil) + if got := len(liElements(t, r)); got != 3 { + t.Fatalf("unpaged ingest has %d elements, want all 3", got) + } + if links, _ := liPaging(t, r)["links"].([]any); len(links) != 0 { + t.Fatalf("unpaged ingest links = %v, want none", liPaging(t, r)["links"]) + } + + // ===== a malformed start cursor is a 400 ===== + if r := f.liIngest(tokenA, map[string]string{"q": "author", "count": "2", "start": "zzz"}); r.Status != 400 || + r.Body["message"] != "Invalid start parameter." { + t.Fatalf("bad start -> %d %v, want 400 Invalid start parameter.", r.Status, r.Body) + } +} + +// TestLinkedinMemberCreatorPostAnalytics: the analytics endpoint verifies the +// ugcPost entity, totals per queryType off the post seq (REACTION +3, +// COMMENT +5, RESHARE +7, IMPRESSION +11) split across two daily buckets, +// accepts the parenthesized and bare entity forms, and pages to empty past +// the data. +func TestLinkedinMemberCreatorPostAnalytics(t *testing.T) { + f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC()) + token, sub := f.liMint() + post, _ := f.liPublish(token, "urn:li:person:"+sub, "the measured post").Body["id"].(string) + if post == "" { + t.Fatal("setup publish failed") + } + base, err := strconv.ParseInt(strings.TrimPrefix(post, "urn:li:ugcPost:"), 10, 64) + if err != nil { + t.Fatalf("post urn %q carries no numeric seq: %v", post, err) + } + analytics := func(entity, queryType, start string) starlark.Response { + query := map[string]string{ + "q": "entity", "entity": entity, "queryType": queryType, + "timeGranularity": "DAY", "dateRange": "(start:20250601,end:20250630)", + } + if start != "" { + query["start"] = start + } + return f.call("analytics", "on_analytics", "GET", "/rest/memberCreatorPostAnalytics", + nil, query, nil, "Bearer "+token) + } + bucketTotal := func(r starlark.Response) int64 { + f.t.Helper() + total := int64(0) + for _, item := range liElements(t, r) { + n, ok := liInt(item.(map[string]any)["count"]) + if !ok { + f.t.Fatalf("bucket count = %v, want a number", item.(map[string]any)["count"]) + } + total += n + } + return total + } + + // ===== an unknown entity is a 404 ===== + if r := analytics("(ugcPost:urn:li:ugcPost:999999)", "REACTION", ""); r.Status != 404 || + r.Body["message"] != "entity not found" { + t.Fatalf("unknown entity -> %d %v, want 404 entity not found", r.Status, r.Body) + } + + // ===== each queryType totals base+3/5/7/11 split across two daily buckets ===== + for queryType, delta := range map[string]int64{"REACTION": 3, "COMMENT": 5, "RESHARE": 7, "IMPRESSION": 11} { + r := analytics("(ugcPost:"+post+")", queryType, "") + if r.Status != 200 { + t.Fatalf("%s -> %d: %v", queryType, r.Status, r.Body) + } + elements := liElements(t, r) + if len(elements) != 2 { + t.Fatalf("%s returned %d buckets, want 2 daily buckets", queryType, len(elements)) + } + for i, item := range elements { + b := item.(map[string]any) + metric := b["metricType"].(map[string]any)[liMetricTypeKey] + if metric != queryType { + t.Fatalf("%s bucket[%d].metricType = %v, want %q under the long key", queryType, i, metric, queryType) + } + target := b["targetEntity"].(map[string]any) + if target["ugcPost"] != post { + t.Fatalf("%s bucket[%d].targetEntity = %v, want the post %s", queryType, i, target, post) + } + // Synthetic daily ranges: consecutive June-2026 days. + dr := b["dateRange"].(map[string]any) + if day, ok := liInt(dr["start"].(map[string]any)["day"]); !ok || day != int64(1+i) { + t.Fatalf("%s bucket[%d].dateRange.start.day = %v, want %d", queryType, i, dr["start"], 1+i) + } + } + if total := bucketTotal(r); total != base+delta { + t.Fatalf("%s buckets total %d, want base %d + %d", queryType, total, base, delta) + } + paging := liPaging(t, r) + if !liNum(paging["count"], 2) || !liNum(paging["start"], 0) { + t.Fatalf("%s paging = %v, want count 2 start 0", queryType, paging) + } + } + + // ===== entity accepts both the parenthesized and bare urn forms ===== + paren := analytics("(ugcPost:"+post+")", "REACTION", "").Body["elements"] + bare := analytics(post, "REACTION", "").Body["elements"] + if !reflect.DeepEqual(paren, bare) { + t.Fatalf("parenthesized vs bare entity disagree: %v vs %v", paren, bare) + } + + // ===== start past the data returns an empty page ===== + r := analytics("(ugcPost:"+post+")", "REACTION", "1") + if got := len(liElements(t, r)); got != 0 { + t.Fatalf("start=1 -> %d elements, want an empty page", got) + } + if paging := liPaging(t, r); !liNum(paging["count"], 0) || !liNum(paging["start"], 1) { + t.Fatalf("empty page paging = %v, want count 0 start 1", paging) + } + + // ===== an unknown queryType falls back to the base total (deviation, as-is) ===== + // Real LinkedIn rejects an invalid queryType; the adapter totals base. + r = analytics("(ugcPost:"+post+")", "BOGUS", "") + if r.Status != 200 { + t.Fatalf("queryType=BOGUS -> %d %v, want 200 (lenient fallback)", r.Status, r.Body) + } + if total := bucketTotal(r); total != base { + t.Fatalf("queryType=BOGUS total = %d, want the bare base %d", total, base) + } +} diff --git a/adapters/oneinch_style_test.go b/adapters/oneinch_style_test.go new file mode 100644 index 00000000..272bdeab --- /dev/null +++ b/adapters/oneinch_style_test.go @@ -0,0 +1,398 @@ +package adapters + +import ( + "math/big" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the oneinch-style adapter scripts directly (lib.star preloaded) +// over a shared store: the v6.0 quote/swap response shapes (token pairs, +// decimal toAmount strings, the 100-point protocol split, router-addressed +// calldata), the approve spender/calldata flow, the address-keyed token +// list, and the 400 error envelopes for missing params and unknown tokens. +// The API is public: no auth gate to exercise. +const ( + oiETHAddr = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEe" + oiUSDCAddr = "0xA0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48" + oiUSDTAddr = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + oiRouter = "0x1111111254EEB25477B68fb85Ed929f73A960582" + oiTrader = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" + oiOneETH = "1000000000000000000" + oiOneUSDC = "1000000" + oiHost = "api.1inch.test" +) + +// oiCalldataRE pins the synthetic calldata shape: 0x + lowercase hex. +var oiCalldataRE = regexp.MustCompile(`^0x[0-9a-f]+$`) + +type oneinchFixture struct { + t *testing.T + vms map[string]*starlark.VM +} + +func newOneinchFixture(t *testing.T) *oneinchFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "oneinch-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(time.Unix(1_750_000_000, 0).UTC()) + em := events.NewEmitter() + t.Cleanup(em.Close) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em, + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &oneinchFixture{t: t, vms: map[string]*starlark.VM{ + "swap": load("swap.star"), "approve": load("approve.star"), "tokens": load("tokens.star"), + }} +} + +// call invokes handler on the named script VM with query parameters. +func (f *oneinchFixture) call(group, handler, method, path string, query map[string]string) starlark.Response { + f.t.Helper() + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: oiHost, Headers: map[string]string{}, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// oiQuote fetches GET /v6.0/1/quote. +func (f *oneinchFixture) oiQuote(src, dst, amount string) starlark.Response { + f.t.Helper() + return f.call("swap", "on_quote", "GET", "/v6.0/1/quote", + map[string]string{"src": src, "dst": dst, "amount": amount}) +} + +// oiAmount parses a decimal-string amount into a big.Int. +func oiAmount(t *testing.T, s any) *big.Int { + t.Helper() + str, ok := s.(string) + if !ok { + t.Fatalf("amount = %v (%T), want a decimal string", s, s) + } + n, ok := new(big.Int).SetString(str, 10) + if !ok { + t.Fatalf("amount %q is not a plain decimal integer", str) + } + return n +} + +// oiNum compares a response number against want whether it arrives as an +// int (handler literal) or a float (round-tripped through a collection). +func oiNum(got any, want int64) bool { + switch n := got.(type) { + case int64: + return n == want + case float64: + return n == float64(want) + } + return false +} + +// oiDesc asserts the 1inch error envelope and returns its description. +func oiDesc(t *testing.T, r starlark.Response) string { + t.Helper() + if !oiNum(r.Body["error"], 400) { + t.Fatalf("error envelope = %v, want {error:400, description}", r.Body) + } + desc, ok := r.Body["description"].(string) + if !ok || desc == "" { + t.Fatalf("error description = %v, want a non-empty string", r.Body["description"]) + } + return desc +} + +// TestOneinchQuoteShapes: the quote envelope — token pairs, the decimal +// toAmount string, the protocol split — plus determinism, case-insensitive +// address matching, amount linearity, and the 400 error envelope. +func TestOneinchQuoteShapes(t *testing.T) { + f := newOneinchFixture(t) + + // ===== a quote returns token pairs, a decimal toAmount and a 100-point split ===== + // toAmount (as-is): real 1inch v6.0 names the field dstAmount. + r := f.oiQuote(oiETHAddr, oiUSDCAddr, oiOneETH) + if r.Status != 200 { + t.Fatalf("quote -> %d: %v", r.Status, r.Body) + } + from, _ := r.Body["fromToken"].(map[string]any) + to, _ := r.Body["toToken"].(map[string]any) + if from["symbol"] != "ETH" || from["name"] != "Ether" || from["address"] != oiETHAddr || + !oiNum(from["decimals"], 18) { + t.Fatalf("fromToken = %v, want the full ETH metadata", from) + } + if to["symbol"] != "USDC" || to["name"] != "USD Coin" || to["address"] != oiUSDCAddr || + !oiNum(to["decimals"], 6) { + t.Fatalf("toToken = %v, want the full USDC metadata", to) + } + toAmount := oiAmount(t, r.Body["toAmount"]) + if toAmount.Sign() <= 0 { + t.Fatalf("toAmount = %v, want a positive integer", r.Body["toAmount"]) + } + protocols, ok := r.Body["protocols"].([]any) + if !ok || len(protocols) != 2 { + t.Fatalf("protocols = %v, want exactly two venues", r.Body["protocols"]) + } + var parts int64 + for _, p := range protocols { + pm, _ := p.(map[string]any) + name, _ := pm["name"].(string) + if name != "UNISWAP_V3" && name != "SUSHISWAP" { + t.Fatalf("protocol name = %v, want a known venue", pm["name"]) + } + part, err := strconv.ParseInt(pm["part"].(string), 10, 64) + if err != nil { + t.Fatalf("protocol part = %v, want a numeric string", pm["part"]) + } + parts += part + } + if parts != 100 { + t.Fatalf("protocol parts sum to %d, want 100", parts) + } + + // ===== quotes are deterministic and address matching is case-insensitive ===== + if again := f.oiQuote(oiETHAddr, oiUSDCAddr, oiOneETH); oiAmount(t, again.Body["toAmount"]).Cmp(toAmount) != 0 { + t.Fatalf("repeat quote toAmount = %v, want %v (deterministic)", again.Body["toAmount"], toAmount) + } + if lower := f.oiQuote(strings.ToLower(oiETHAddr), strings.ToLower(oiUSDCAddr), oiOneETH); lower.Status != 200 || + oiAmount(t, lower.Body["toAmount"]).Cmp(toAmount) != 0 { + t.Fatalf("lowercase quote = %d %v, want the same toAmount", lower.Status, lower.Body["toAmount"]) + } + + // ===== the toAmount scales linearly with the input amount ===== + // Same pair, 2 ETH in -> exactly 2x out (the pseudo-rate is per pair). + doubled := f.oiQuote(oiETHAddr, oiUSDCAddr, "2000000000000000000") + if want := new(big.Int).Lsh(toAmount, 1); oiAmount(t, doubled.Body["toAmount"]).Cmp(want) != 0 { + t.Fatalf("2 ETH quote = %v, want %v (linear)", doubled.Body["toAmount"], want) + } + + // ===== a same-token quote scales the amount by the pseudo-rate (as-is) ===== + // Real 1inch rejects src == dst; the simulator quotes a non-identity rate. + same := f.oiQuote(oiUSDCAddr, oiUSDCAddr, oiOneUSDC) + if same.Status != 200 { + t.Fatalf("same-token quote -> %d: %v", same.Status, same.Body) + } + if got := oiAmount(t, same.Body["toAmount"]); got.Cmp(big.NewInt(1000000)) == 0 { + t.Fatalf("same-token toAmount = %v, want the pseudo-rate multiple (as-is)", got) + } + + // ===== missing params and unknown tokens are 400 error envelopes ===== + if r := f.call("swap", "on_quote", "GET", "/v6.0/1/quote", map[string]string{}); r.Status != 400 { + t.Fatalf("quote without params -> %d, want 400", r.Status) + } else if !strings.Contains(oiDesc(t, r), "required") { + t.Fatalf("quote error = %q, want the required-params message", r.Body["description"]) + } + unknown := "0x000000000000000000000000000000000000dead" + if r := f.oiQuote(unknown, oiUSDCAddr, oiOneETH); r.Status != 400 || + !strings.HasPrefix(oiDesc(t, r), "Unknown src token: ") { + t.Fatalf("unknown src -> %d %v, want the 400 unknown-src envelope", r.Status, r.Body) + } + if r := f.oiQuote(oiETHAddr, unknown, oiOneETH); r.Status != 400 || + !strings.HasPrefix(oiDesc(t, r), "Unknown dst token: ") { + t.Fatalf("unknown dst -> %d %v, want the 400 unknown-dst envelope", r.Status, r.Body) + } +} + +// TestOneinchSwapCalldata: the swap envelope — router-addressed unsigned +// calldata with gas/gasPrice, amount consistency with the quote, the +// optional (ignored) slippage parameter, and its 400s. +func TestOneinchSwapCalldata(t *testing.T) { + f := newOneinchFixture(t) + swap := func(query map[string]string) starlark.Response { + f.t.Helper() + return f.call("swap", "on_swap", "GET", "/v6.0/1/swap", query) + } + base := map[string]string{"src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader} + + // ===== a swap returns router-addressed calldata with gas and gasPrice ===== + // The tx targets the 1inch router (same address the approve flow returns). + r := swap(base) + if r.Status != 200 { + t.Fatalf("swap -> %d: %v", r.Status, r.Body) + } + tx, ok := r.Body["tx"].(map[string]any) + if !ok { + t.Fatalf("swap tx = %v, want an object", r.Body["tx"]) + } + if tx["to"] != oiRouter || tx["from"] != oiTrader || tx["value"] != "0" { + t.Fatalf("tx routing = %v, want router + trader + zero value", tx) + } + data, _ := tx["data"].(string) + if !oiCalldataRE.MatchString(data) || !strings.HasPrefix(data, "0x12e7c2a0") { + t.Fatalf("tx.data = %q, want the synthetic hex calldata prefix", data) + } + if tx["gasPrice"] != "15000000000" { + t.Fatalf("tx.gasPrice = %v, want the fixed wei string", tx["gasPrice"]) + } + if gas, ok := tx["gas"].(int64); !ok || gas < 180000 || gas >= 280000 { + t.Fatalf("tx.gas = %v (%T), want the bounded int estimate", tx["gas"], tx["gas"]) + } + from, _ := r.Body["fromToken"].(map[string]any) + to, _ := r.Body["toToken"].(map[string]any) + if from["symbol"] != "ETH" || to["symbol"] != "USDC" || !oiNum(to["decimals"], 6) { + t.Fatalf("swap tokens = %v / %v, want the pair metadata", from, to) + } + if _, has := from["name"]; has { + t.Fatalf("swap fromToken = %v, want no name field (as-is: slimmer than the quote shape)", from) + } + + // ===== the swap toAmount matches the quote for the same input ===== + quote := f.oiQuote(oiETHAddr, oiUSDCAddr, oiOneETH) + if oiAmount(t, r.Body["toAmount"]).Cmp(oiAmount(t, quote.Body["toAmount"])) != 0 { + t.Fatalf("swap toAmount = %v, want the quote's %v", r.Body["toAmount"], quote.Body["toAmount"]) + } + + // ===== slippage is optional and ignored (as-is) ===== + // Real v6.0 requires slippage on /swap; the simulator accepts any or none + // and returns the same routing. + omit := swap(map[string]string{"src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader}) + if omit.Status != 200 || oiAmount(t, omit.Body["toAmount"]).Cmp(oiAmount(t, r.Body["toAmount"])) != 0 { + t.Fatalf("swap without slippage -> %d %v, want the same toAmount", omit.Status, omit.Body["toAmount"]) + } + wide := swap(map[string]string{ + "src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader, "slippage": "50", + }) + if wide.Status != 200 || oiAmount(t, wide.Body["toAmount"]).Cmp(oiAmount(t, r.Body["toAmount"])) != 0 { + t.Fatalf("swap with slippage 50 -> %d %v, want the same toAmount (ignored)", wide.Status, wide.Body["toAmount"]) + } + + // ===== missing params and unknown tokens are 400s ===== + if r := swap(map[string]string{"src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH}); r.Status != 400 || + !oiNum(r.Body["error"], 400) { + t.Fatalf("swap without fromAddress -> %d %v, want the 400 envelope", r.Status, r.Body) + } + unknown := "0x000000000000000000000000000000000000dead" + if r := swap(map[string]string{"src": unknown, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader}); r.Status != 400 { + t.Fatalf("swap unknown src -> %d, want 400", r.Status) + } + if r := swap(map[string]string{"src": oiETHAddr, "dst": unknown, "amount": oiOneETH, "fromAddress": oiTrader}); r.Status != 400 { + t.Fatalf("swap unknown dst -> %d, want 400", r.Status) + } +} + +// TestOneinchApproveFlow: the spender address is the router, and the approve +// calldata targets the token with the max-uint256 allowance. +func TestOneinchApproveFlow(t *testing.T) { + f := newOneinchFixture(t) + + // ===== the spender is the router contract address ===== + r := f.call("approve", "on_get_spender", "GET", "/v6.0/1/approve/spender", nil) + if r.Status != 200 || r.Body["address"] != oiRouter { + t.Fatalf("spender -> %d %v, want the router address", r.Status, r.Body) + } + + // ===== approve calldata targets the token with the max allowance ===== + // The selector 095ea7b3 (ERC20 approve) follows the synthetic prefix, and + // the allowance is 2^256-1 in decimal. + r = f.call("approve", "on_get_approve_calldata", "GET", "/v6.0/1/approve/calldata", + map[string]string{"token": oiUSDCAddr}) + if r.Status != 200 { + t.Fatalf("approve calldata -> %d: %v", r.Status, r.Body) + } + if r.Body["to"] != oiUSDCAddr { + t.Fatalf("approve to = %v, want the token's own address", r.Body["to"]) + } + data, _ := r.Body["data"].(string) + if !oiCalldataRE.MatchString(data) || !strings.HasPrefix(data, "0x12e7c2a095ea7b3") { + t.Fatalf("approve data = %q, want the synthetic prefix + approve selector", data) + } + max := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + if r.Body["allowance"] != max.String() { + t.Fatalf("allowance = %v, want 2^256-1 (%s)", r.Body["allowance"], max.String()) + } + + // ===== a missing or unknown token is a 400 ===== + if r := f.call("approve", "on_get_approve_calldata", "GET", "/v6.0/1/approve/calldata", + map[string]string{}); r.Status != 400 || !oiNum(r.Body["error"], 400) { + t.Fatalf("calldata without token -> %d %v, want the 400 envelope", r.Status, r.Body) + } + if r := f.call("approve", "on_get_approve_calldata", "GET", "/v6.0/1/approve/calldata", + map[string]string{"token": "0x000000000000000000000000000000000000dead"}); r.Status != 400 || + !strings.HasPrefix(oiDesc(t, r), "Unknown token: ") { + t.Fatalf("calldata unknown token -> %d %v, want the unknown-token envelope", r.Status, r.Body) + } +} + +// TestOneinchTokenList: the address-keyed token map with its six seeded +// tokens, and that every listed token is quotable as a source. +func TestOneinchTokenList(t *testing.T) { + f := newOneinchFixture(t) + + // ===== the token list is an address-keyed map of six tokens ===== + // logoURI is null (as-is): the real list carries logo URIs. + r := f.call("tokens", "on_get_tokens", "GET", "/v6.0/1/tokens", nil) + if r.Status != 200 { + t.Fatalf("tokens -> %d: %v", r.Status, r.Body) + } + tokens, ok := r.Body["tokens"].(map[string]any) + if !ok { + t.Fatalf("tokens = %v, want an address-keyed map", r.Body["tokens"]) + } + if len(tokens) != 6 { + t.Fatalf("token count = %d, want the six seeded tokens", len(tokens)) + } + usdc, ok := tokens[oiUSDCAddr].(map[string]any) + if !ok { + t.Fatalf("no USDC entry keyed by %s", oiUSDCAddr) + } + if usdc["symbol"] != "USDC" || usdc["name"] != "USD Coin" || !oiNum(usdc["decimals"], 6) || + usdc["address"] != oiUSDCAddr { + t.Fatalf("USDC entry = %v, want the full metadata", usdc) + } + if usdc["logoURI"] != nil || usdc["eip2612"] != false { + t.Fatalf("USDC entry = %v, want null logoURI and eip2612 false", usdc) + } + if _, has := tokens[oiETHAddr]; !has { + t.Fatalf("no ETH sentinel entry keyed by %s", oiETHAddr) + } + + // ===== every token in the list is quotable as a source ===== + // Whole-unit amounts: a dust amount underflows to 0 when the source has + // more decimals than the destination (integer scaling). + for addr := range tokens { + q := f.oiQuote(addr, oiUSDTAddr, oiOneETH) + if q.Status != 200 { + t.Fatalf("quote from %s -> %d: %v", addr, q.Status, q.Body) + } + if oiAmount(t, q.Body["toAmount"]).Sign() <= 0 { + t.Fatalf("quote from %s toAmount = %v, want positive", addr, q.Body["toAmount"]) + } + } +} diff --git a/adapters/onfido-style/scripts/applicants.star b/adapters/onfido-style/scripts/applicants.star index 947239eb..57f9fe91 100644 --- a/adapters/onfido-style/scripts/applicants.star +++ b/adapters/onfido-style/scripts/applicants.star @@ -18,9 +18,13 @@ def on_create_applicant(req): dob = body.get("dob", "") if first_name == "" or last_name == "": - return respond(422, _err("validation_error", "first_name and last_name are required", { - "first_name": ["can't be blank"], - })) + # Real Onfido flags exactly the blank fields, not a fixed list. + fields = {} + if first_name == "": + fields["first_name"] = ["can't be blank"] + if last_name == "": + fields["last_name"] = ["can't be blank"] + return respond(422, _err("validation_error", "first_name and last_name are required", fields)) seq = store_kv_incr("onfido", "applicant_seq") applicant_id = _gen_id("app", seq) diff --git a/adapters/onfido-style/scripts/documents.star b/adapters/onfido-style/scripts/documents.star index 8361073c..791d2ad1 100644 --- a/adapters/onfido-style/scripts/documents.star +++ b/adapters/onfido-style/scripts/documents.star @@ -26,7 +26,12 @@ def on_upload_document(req): if ac.get(applicant_id) == None: return respond(404, _err("not_found", "Applicant not found", None)) - doc_type = body.get("type", "passport") + # v3.6 requires type on document uploads; only side defaults to front. + doc_type = body.get("type", "") + if doc_type == "": + return respond(422, _err("validation_error", "type is required", { + "type": ["can't be blank"], + })) side = body.get("side", "front") file_name = body.get("file_name", "document.jpg") diff --git a/adapters/onfido_style_test.go b/adapters/onfido_style_test.go new file mode 100644 index 00000000..a661e807 --- /dev/null +++ b/adapters/onfido_style_test.go @@ -0,0 +1,456 @@ +package adapters + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the onfido-style adapter scripts directly (lib.star preloaded) over +// a shared store and a VIRTUAL clock: the Token-scheme credential gate, the +// applicant -> document -> live photo -> check KYC flow, the derive-on-read +// check machine (in_progress until +3s, then complete clear|consider, with +// the check.completed webhook MACed Onfido-style over the delivered bytes and +// fired exactly once), and the /webhooks receiver that re-verifies +// X-SHA2-Signature over the exact raw bytes — all against Onfido's +// {error:{type,message,fields}} envelope. +const ( + onfidoAuth = "Token token-onfido-vm" + onfidoWebhookKey = "stunt_onfido_mock_signing_key" + onfidoSHA2SigWarn = "X-SHA2-Signature header is required" + onfidoSHA2SigMatch = "X-SHA2-Signature does not match the request body" +) + +type onfidoFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + emitter *events.Emitter + host string +} + +func newOnfidoFixture(t *testing.T, start time.Time) *onfidoFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "onfido-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + em := events.NewEmitter() + t.Cleanup(em.Close) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em, + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &onfidoFixture{t: t, vc: vc, vms: map[string]*starlark.VM{ + "applicants": load("applicants.star"), "documents": load("documents.star"), + "checks": load("checks.star"), "webhooks": load("webhooks.star"), + }, emitter: em, host: "api.onfido.test"} +} + +// call invokes handler on the named script VM; auth is the full Authorization +// header value ("" = header absent). +func (f *onfidoFixture) call(group, handler, method, path string, params map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// callWebhook drives the receiver with the exact bytes a signed Onfido +// delivery would carry: raw body on the wire plus the X-SHA2-Signature header +// ("" = header absent). No API token — Onfido's webhook deliveries never +// carry one; the MAC is the credential. +func (f *onfidoFixture) callWebhook(raw string, sig string) starlark.Response { + f.t.Helper() + headers := map[string]string{"Content-Type": "application/json"} + if sig != "" { + headers["X-SHA2-Signature"] = sig + } + resp, err := f.vms["webhooks"].Call("on_webhook", starlark.Request{ + Method: "POST", Path: "/v3.6/webhooks", Host: f.host, Headers: headers, RawBody: raw, + }) + if err != nil { + f.t.Fatalf("on_webhook: %v", err) + } + return resp +} + +// onfidoDelivery is one webhook POST captured by the sink. +type onfidoDelivery struct { + body []byte + sig string +} + +// captureWebhooks registers a sink for the fixture's event namespace and +// returns a collector over the raw deliveries (the emitter's {type, payload} +// envelopes — Onfido's bare {"payload": ...} body rides inside payload). +func (f *onfidoFixture) captureWebhooks() func() []onfidoDelivery { + f.t.Helper() + var mu sync.Mutex + var got []onfidoDelivery + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + got = append(got, onfidoDelivery{body: b, sig: r.Header.Get("X-SHA2-Signature")}) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + f.t.Cleanup(sink.Close) + f.emitter.Register("test", sink.URL) + return func() []onfidoDelivery { + mu.Lock() + defer mu.Unlock() + return append([]onfidoDelivery(nil), got...) + } +} + +// onfidoMAC computes Onfido's webhook signature: hex(HMAC-SHA256(key, body)). +func onfidoMAC(body []byte) string { + mac := hmac.New(sha256.New, []byte(onfidoWebhookKey)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +// onfidoVerifySig checks X-SHA2-Signature against the exact delivered bytes +// per the README scheme; a wrong key must not match either. +func onfidoVerifySig(t *testing.T, d onfidoDelivery) { + t.Helper() + if want := onfidoMAC(d.body); !hmac.Equal([]byte(want), []byte(d.sig)) { + t.Fatalf("X-SHA2-Signature = %q, want %q (HMAC-SHA256(%q, %s))", d.sig, want, onfidoWebhookKey, d.body) + } +} + +// onfidoErr returns the {type, message, fields} error object from a response. +func onfidoErr(t *testing.T, r starlark.Response) map[string]any { + t.Helper() + e, ok := r.Body["error"].(map[string]any) + if !ok { + t.Fatalf("status %d body %v has no Onfido error object", r.Status, r.Body) + } + return e +} + +// onfidoFields returns the error object's fields map (may be nil). +func onfidoFields(e map[string]any) map[string]any { + fields, _ := e["fields"].(map[string]any) + return fields +} + +func TestOnfidoApplicantCheckLifecycle(t *testing.T) { + base := time.Date(2026, 8, 14, 9, 0, 0, 0, time.UTC) + f := newOnfidoFixture(t, base) + + // ===== a missing or non-Token Authorization header is 401 authorization_error ===== + // Onfido gates on the "Token" scheme, not Bearer; an empty token is no token. + for _, auth := range []string{"", "Bearer token-onfido-vm", "Token "} { + r := f.call("applicants", "on_create_applicant", "POST", "/v3.6/applicants", nil, + map[string]any{"first_name": "Jane", "last_name": "Doe"}, auth) + if r.Status != 401 { + t.Fatalf("auth %q -> %d, want 401", auth, r.Status) + } + if e := onfidoErr(t, r); e["type"] != "authorization_error" { + t.Fatalf("auth %q error type = %v, want authorization_error", auth, e["type"]) + } + } + + // ===== applicant create flags exactly the blank names and reads back by id ===== + // Real Onfido's fields object names the blank params, not a fixed list. + blank := f.call("applicants", "on_create_applicant", "POST", "/v3.6/applicants", nil, + map[string]any{"first_name": "Jane"}, onfidoAuth) + if blank.Status != 422 { + t.Fatalf("blank last_name -> %d, want 422", blank.Status) + } + fields := onfidoFields(onfidoErr(t, blank)) + if got, ok := fields["last_name"].([]any); !ok || len(got) != 1 || got[0] != "can't be blank" { + t.Fatalf("blank last_name fields = %v, want last_name: [can't be blank]", fields) + } + if _, has := fields["first_name"]; has { + t.Fatalf("blank last_name flags first_name too: %v", fields) + } + + created := f.call("applicants", "on_create_applicant", "POST", "/v3.6/applicants", nil, + map[string]any{"first_name": "Jane", "last_name": "Doe", "dob": "1990-05-15", "email": "jane@example.test"}, onfidoAuth) + if created.Status != 201 { + t.Fatalf("create applicant -> %d: %v", created.Status, created.Body) + } + applicantID, _ := created.Body["id"].(string) + if applicantID != "app-000001" { + t.Fatalf("first applicant id = %q, want app-000001", applicantID) + } + if created.Body["href"] != "/v3.6/applicants/"+applicantID { + t.Fatalf("applicant href = %v", created.Body["href"]) + } + if created.Body["created_at"] == "" || created.Body["created_at"] == nil { + t.Fatalf("applicant created_at missing: %v", created.Body) + } + + got := f.call("applicants", "on_get_applicant", "GET", "/v3.6/applicants/"+applicantID, + map[string]string{"applicant_id": applicantID}, nil, onfidoAuth) + if got.Status != 200 || got.Body["first_name"] != "Jane" || got.Body["last_name"] != "Doe" || + got.Body["dob"] != "1990-05-15" || got.Body["email"] != "jane@example.test" { + t.Fatalf("get applicant -> %d %v", got.Status, got.Body) + } + missing := f.call("applicants", "on_get_applicant", "GET", "/v3.6/applicants/app-999999", + map[string]string{"applicant_id": "app-999999"}, nil, onfidoAuth) + if missing.Status != 404 || onfidoErr(t, missing)["type"] != "not_found" { + t.Fatalf("unknown applicant -> %d %v, want 404 not_found", missing.Status, missing.Body) + } + + // ===== document and live photo uploads bind to a real applicant and default side ===== + // v3.6 requires type; side defaults to front; an unknown applicant is a 404. + noType := f.call("documents", "on_upload_document", "POST", "/v3.6/documents", nil, + map[string]any{"applicant_id": applicantID}, onfidoAuth) + if noType.Status != 422 { + t.Fatalf("document without type -> %d, want 422", noType.Status) + } + if got := onfidoFields(onfidoErr(t, noType))["type"]; got == nil { + t.Fatalf("document without type fields = %v, want type flagged", onfidoErr(t, noType)) + } + noApplicant := f.call("documents", "on_upload_document", "POST", "/v3.6/documents", nil, + map[string]any{"applicant_id": "app-999999", "type": "passport"}, onfidoAuth) + if noApplicant.Status != 404 { + t.Fatalf("document for unknown applicant -> %d, want 404", noApplicant.Status) + } + + doc := f.call("documents", "on_upload_document", "POST", "/v3.6/documents", nil, + map[string]any{"applicant_id": applicantID, "type": "driving_licence"}, onfidoAuth) + if doc.Status != 201 || doc.Body["id"] != "doc-000001" || doc.Body["side"] != "front" { + t.Fatalf("upload document -> %d %v, want 201 doc-000001 side front", doc.Status, doc.Body) + } + photo := f.call("documents", "on_upload_live_photo", "POST", "/v3.6/live_photos", nil, + map[string]any{"applicant_id": applicantID}, onfidoAuth) + if photo.Status != 201 || photo.Body["id"] != "lph-000001" { + t.Fatalf("upload live photo -> %d %v, want 201 lph-000001", photo.Status, photo.Body) + } + + // ===== check create demands report_names and a known applicant ===== + noReports := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil, + map[string]any{"applicant_id": applicantID}, onfidoAuth) + if noReports.Status != 422 || onfidoFields(onfidoErr(t, noReports))["report_names"] == nil { + t.Fatalf("check without report_names -> %d %v, want 422 flagging report_names", noReports.Status, noReports.Body) + } + badApplicant := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil, + map[string]any{"applicant_id": "app-999999", "report_names": []any{"document"}}, onfidoAuth) + if badApplicant.Status != 404 { + t.Fatalf("check for unknown applicant -> %d, want 404", badApplicant.Status) + } + + check := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil, + map[string]any{"applicant_id": applicantID, "report_names": []any{"document", "facial_similarity_photo"}}, onfidoAuth) + if check.Status != 201 { + t.Fatalf("create check -> %d: %v", check.Status, check.Body) + } + checkID, _ := check.Body["id"].(string) + if checkID != "chk-000001" || check.Body["status"] != "in_progress" || check.Body["result"] != nil { + t.Fatalf("created check = %v, want chk-000001 in_progress result null", check.Body) + } + if _, hasBreakdown := check.Body["breakdown"]; hasBreakdown { + t.Fatalf("in_progress check already has a breakdown: %v", check.Body) + } + + // ===== the check completes from the clock and emits check.completed exactly once ===== + // The sink must be live before the transition fires. + deliveries := f.captureWebhooks() + + // t0 and t0+2s: still inside the +3s window -> in_progress. + for _, hop := range []time.Duration{0, 2 * time.Second} { + f.vc.Advance(hop) + early := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+checkID, + map[string]string{"check_id": checkID}, nil, onfidoAuth) + if early.Status != 200 || early.Body["status"] != "in_progress" || early.Body["result"] != nil { + t.Fatalf("check at +%v -> %d %v, want in_progress result null", hop, early.Status, early.Body) + } + if len(deliveries()) != 0 { + t.Fatalf("webhook emitted before completion at +%v", hop) + } + } + + // t0+4s: past _done_at -> complete with result clear and a per-report + // breakdown (documents on file, so awaiting_applicant is skipped). + f.vc.Advance(2 * time.Second) + done := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+checkID, + map[string]string{"check_id": checkID}, nil, onfidoAuth) + if done.Status != 200 || done.Body["status"] != "complete" || done.Body["result"] != "clear" { + t.Fatalf("check at +4s -> %d %v, want complete clear", done.Status, done.Body) + } + breakdown, ok := done.Body["breakdown"].(map[string]any) + if !ok { + t.Fatalf("complete check breakdown = %v, want object", done.Body["breakdown"]) + } + for _, name := range []string{"document", "facial_similarity_photo"} { + per, ok := breakdown[name].(map[string]any) + if !ok || per["result"] != "clear" { + t.Fatalf("breakdown[%s] = %v, want result clear", name, breakdown[name]) + } + } + + // The transition delivered exactly one signed check.completed. + dv := deliveries() + if len(dv) != 1 { + t.Fatalf("got %d deliveries after completion, want exactly 1", len(dv)) + } + var env struct { + Type string `json:"type"` + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(dv[0].body, &env); err != nil { + t.Fatalf("delivery body %s unparsable: %v", dv[0].body, err) + } + if env.Type != "check.completed" { + t.Fatalf("delivery type = %q, want check.completed", env.Type) + } + // Onfido's webhook object rides inside the emitter envelope's payload. + wh, ok := env.Payload["payload"].(map[string]any) + if !ok { + t.Fatalf("delivery payload = %v, want the Onfido {resource_type, action, object} object", env.Payload) + } + if wh["resource_type"] != "check" || wh["action"] != "check.completed" { + t.Fatalf("webhook resource/action = %v/%v, want check/check.completed", wh["resource_type"], wh["action"]) + } + obj, _ := wh["object"].(map[string]any) + if obj["id"] != checkID || obj["status"] != "complete" || obj["result"] != "clear" { + t.Fatalf("webhook object = %v, want the completing %s", obj, checkID) + } + onfidoVerifySig(t, dv[0]) + + // Later polls read the persisted terminal state and never re-emit. + for i := 0; i < 2; i++ { + again := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+checkID, + map[string]string{"check_id": checkID}, nil, onfidoAuth) + if again.Body["status"] != "complete" || again.Body["result"] != "clear" { + t.Fatalf("repeat poll -> %v, want complete clear", again.Body) + } + } + if n := len(deliveries()); n != 1 { + t.Fatalf("repeat polls emitted %d more deliveries, want 0", n-1) + } + + // ===== simulate_fail completes with consider and consider breakdowns ===== + // Simulator extension: the body flag replaces Onfido's special sandbox + // documents as the consider driver; timing is unchanged. + fail := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil, + map[string]any{"applicant_id": applicantID, "report_names": []any{"document"}, "simulate_fail": true}, onfidoAuth) + if fail.Status != 201 || fail.Body["status"] != "in_progress" { + t.Fatalf("create failing check -> %d %v", fail.Status, fail.Body) + } + failID, _ := fail.Body["id"].(string) + f.vc.Advance(4 * time.Second) + flagged := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+failID, + map[string]string{"check_id": failID}, nil, onfidoAuth) + if flagged.Status != 200 || flagged.Body["status"] != "complete" || flagged.Body["result"] != "consider" { + t.Fatalf("failing check -> %d %v, want complete consider", flagged.Status, flagged.Body) + } + if bd, _ := flagged.Body["breakdown"].(map[string]any); bd == nil { + t.Fatalf("failing breakdown = %v, want object", flagged.Body["breakdown"]) + } else if per, _ := bd["document"].(map[string]any); per == nil || per["result"] != "consider" { + t.Fatalf("failing breakdown[document] = %v, want result consider", bd["document"]) + } + // Its completion is its own signed delivery (2 total, one per check). + if n := len(deliveries()); n != 2 { + t.Fatalf("deliveries after both checks = %d, want 2", n) + } + last := deliveries()[1] + onfidoVerifySig(t, last) + var failEnv struct { + Payload struct { + Payload struct { + Object struct { + Result string `json:"result"` + } `json:"object"` + } `json:"payload"` + } `json:"payload"` + } + if err := json.Unmarshal(last.body, &failEnv); err != nil || failEnv.Payload.Payload.Object.Result != "consider" { + t.Fatalf("failing delivery = %s, want object.result consider", last.body) + } +} + +// TestOnfidoWebhookReceiverMAC: POST /v3.6/webhooks is the local stand-in +// for the user's own Onfido webhook endpoint, so it re-verifies +// X-SHA2-Signature over the exact bytes on the wire — the same MAC the +// adapter produces outbound. +func TestOnfidoWebhookReceiverMAC(t *testing.T) { + base := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC) + f := newOnfidoFixture(t, base) + + body := `{"payload":{"resource_type":"check","action":"check.completed","object":{"id":"chk-000042","status":"complete","result":"clear"}}}` + goodSig := onfidoMAC([]byte(body)) + + // ===== the webhook receiver MACs the exact raw bytes ===== + // No header, a tampered signature, and a signature over different bytes + // are all 401 authorization_error; only the MAC over the verbatim body + // passes and is acknowledged. + if r := f.callWebhook(body, ""); r.Status != 401 { + t.Fatalf("webhook without signature -> %d, want 401", r.Status) + } else if e := onfidoErr(t, r); e["type"] != "authorization_error" || e["message"] != onfidoSHA2SigWarn { + t.Fatalf("missing-signature error = %v, want %q", e, onfidoSHA2SigWarn) + } + + if r := f.callWebhook(body, onfidoMAC([]byte(body+" "))); r.Status != 401 { + t.Fatalf("webhook with signature over other bytes -> %d, want 401", r.Status) + } else if e := onfidoErr(t, r); e["type"] != "authorization_error" || e["message"] != onfidoSHA2SigMatch { + t.Fatalf("mismatched-signature error = %v, want %q", e, onfidoSHA2SigMatch) + } + + if r := f.callWebhook(body+" ", goodSig); r.Status != 401 { + t.Fatalf("tampered body -> %d, want 401", r.Status) + } + + ok := f.callWebhook(body, goodSig) + if ok.Status != 200 || ok.Body["received"] != true { + t.Fatalf("correctly signed webhook -> %d %v, want 200 {received: true}", ok.Status, ok.Body) + } + + // Header names are case-insensitive but the hex digest is compared as a + // string: uppercase hex is a different signature, not the same MAC. + if r := f.callWebhook(body, strings.ToUpper(goodSig)); r.Status != 401 { + t.Fatalf("uppercase hex signature -> %d, want 401 (MACs are case-sensitive)", r.Status) + } +} diff --git a/adapters/opensea-style/scripts/lib.star b/adapters/opensea-style/scripts/lib.star index 84d883a8..d31c5f04 100644 --- a/adapters/opensea-style/scripts/lib.star +++ b/adapters/opensea-style/scripts/lib.star @@ -14,7 +14,8 @@ def _require_xapikey(req): # Go canonicalizes header keys: X-API-KEY becomes X-Api-Key. apikey = headers.get("X-Api-Key", headers.get("X-API-KEY", headers.get("x-api-key", ""))) if apikey == None or apikey == "": - return respond(401, {"error": "X-API-KEY header is required"}) + # Real API 401s use the V1ErrorWrapper envelope ({"errors": [...]}). + return respond(401, {"errors": ["X-API-KEY header is required"]}) return None # --- deterministic hashing (consistent with eth-jsonrpc / etherscan adapters) --- @@ -181,7 +182,7 @@ def _make_listing(slug, nft_addr, nft_id, price_wei, offerer): "parameters": { "offerer": offerer, "zone": "0x0000000000000000000000000000000000000000", - "zone_hash": "0x" + _hex32(0) * 2, + "zone_hash": "0x" + _hex32(0) * 8, # Seaport zone_hash is bytes32 (64 hex). "offer": [{ "itemType": _ITEM_ERC721, "token": nft_addr, @@ -220,7 +221,7 @@ def _make_offer(slug, nft_addr, nft_id, offer_amount, offerer): "parameters": { "offerer": offerer, "zone": "0x0000000000000000000000000000000000000000", - "zone_hash": "0x" + _hex32(0) * 2, + "zone_hash": "0x" + _hex32(0) * 8, # Seaport zone_hash is bytes32 (64 hex). "offer": [{ "itemType": _ITEM_NATIVE, "token": "0x0000000000000000000000000000000000000000", diff --git a/adapters/opensea_style_test.go b/adapters/opensea_style_test.go new file mode 100644 index 00000000..25f9697e --- /dev/null +++ b/adapters/opensea_style_test.go @@ -0,0 +1,512 @@ +package adapters + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the opensea-style adapter scripts directly (lib.star preloaded) over +// a shared store: the X-API-KEY gate on every surface, the seeded NFT/ +// collection/event reads with their chain+address shapes, limit/next cursor +// pagination, the Seaport listing/offer order shapes, and the stateful +// create-offer flow. +const ( + openseaKey = "opensea-test-key" + openseaHost = "api.opensea.test" + openseaPunks = "0x0000000000000000000000000000000000000100" + openseaZero = "0x0000000000000000000000000000000000000000" + openseaMaker = "0x0000000000000000000000000000000000000001" + openseaOfferer = "0x0000000000000000000000000000000000000002" + openseaSeaport = "0x0000000000000068F116a894984e2DB1123eB395" + openseaWei05 = "50000000000000000" // 0.05 ETH, the seeded listing price + openseaWei03 = "30000000000000000" // 0.03 ETH, the seeded offer amount +) + +type openseaFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newOpenseaFixture(t *testing.T, start time.Time) *openseaFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "opensea-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &openseaFixture{t: t, vc: vc, host: openseaHost, vms: map[string]*starlark.VM{ + "assets": load("assets.star"), "collections": load("collections.star"), + "events": load("events.star"), "orders": load("orders.star"), + }} +} + +func (f *openseaFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, apikey string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if apikey != "" { + headers["X-API-KEY"] = apikey + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// openseaAssets fetches the asset page list (or fails the test). +func (f *openseaFixture) openseaAssets(query map[string]string) []map[string]any { + f.t.Helper() + r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, query, nil, openseaKey) + if r.Status != 200 { + f.t.Fatalf("list assets %v -> %d: %v", query, r.Status, r.Body) + } + out := []map[string]any{} + for i, a := range openseaList(f.t, r.Body["assets"], "assets") { + out = append(out, openseaMap(f.t, a, "assets["+strconv.Itoa(i)+"]")) + } + return out +} + +func openseaMap(t *testing.T, v any, what string) map[string]any { + t.Helper() + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("%s is %T(%v), want object", what, v, v) + } + return m +} + +func openseaList(t *testing.T, v any, what string) []any { + t.Helper() + l, ok := v.([]any) + if !ok { + t.Fatalf("%s is %T(%v), want array", what, v, v) + } + return l +} + +// openseaHash32 asserts a 0x-prefixed 64-char lowercase hex value (the mock's +// deterministic pseudo-keccak width) and returns it. +func openseaHash32(t *testing.T, v any, what string) string { + t.Helper() + s, ok := v.(string) + if !ok || !strings.HasPrefix(s, "0x") || len(s) != 66 { + t.Fatalf("%s = %v, want 0x + 64 hex chars", what, v) + } + for i := 2; i < len(s); i++ { + if !strings.ContainsRune("0123456789abcdef", rune(s[i])) { + t.Fatalf("%s has non-hex char %q at %d", what, s[i], i) + } + } + return s +} + +func TestOpenSeaReadsGateAndPaging(t *testing.T) { + f := newOpenseaFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + + // ===== the X-API-KEY gate 401s every surface with the V1ErrorWrapper envelope ===== + // Every route requires the header; the real API answers 401 {"errors": [...]}. + surfaces := []struct{ group, handler, method, path string }{ + {"assets", "on_list_assets", "GET", "/api/v2/assets"}, + {"assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/" + openseaPunks + "/1"}, + {"collections", "on_get_collection", "GET", "/api/v2/collections/mock-punks"}, + {"events", "on_list_events", "GET", "/api/v2/events"}, + {"orders", "on_list_listings", "GET", "/api/v2/orders/ethereum/seaport/listings"}, + {"orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers"}, + {"orders", "on_create_offer", "POST", "/api/v2/offers"}, + } + for _, s := range surfaces { + r := f.call(s.group, s.handler, s.method, s.path, nil, nil, nil, "") + if r.Status != 401 { + t.Fatalf("%s without X-API-KEY -> %d, want 401", s.path, r.Status) + } + errs := openseaList(t, r.Body["errors"], s.path+" 401 errors") + if len(errs) != 1 || errs[0] != "X-API-KEY header is required" { + t.Fatalf("%s 401 body = %v, want {errors: [X-API-KEY header is required]}", s.path, r.Body) + } + } + // Any non-empty key passes (documented deviation: no real key validation), + // and header names are case-insensitive per RFC 9110. + if r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, nil, nil, "literally-any-value"); r.Status != 200 { + t.Fatalf("any non-empty key -> %d, want 200", r.Status) + } + lower, err := f.vms["assets"].Call("on_list_assets", starlark.Request{ + Method: "GET", Path: "/api/v2/assets", Host: f.host, + Headers: map[string]string{"x-api-key": openseaKey}, + }) + if err != nil || lower.Status != 200 { + t.Fatalf("lowercase x-api-key -> %d (%v), want 200", lower.Status, err) + } + + // ===== the asset list seeds five mock-punks NFTs and filters by collection_slug ===== + all := f.openseaAssets(nil) + if len(all) != 5 { + t.Fatalf("seeded assets = %d, want 5", len(all)) + } + if _, has := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, nil, nil, openseaKey).Body["next"]; has { + t.Fatalf("unpaged asset list carries a next cursor") + } + first := all[0] + if first["id"] != "1" || first["token_id"] != "1" || first["token_address"] != openseaPunks { + t.Fatalf("asset[0] identity = %v", first) + } + if first["name"] != "Mock Punk #1" || first["chain"] != "ethereum" || !strings.HasSuffix(first["image_url"].(string), "/1.png") { + t.Fatalf("asset[0] metadata = %v", first) + } + if openseaMap(t, first["collection"], "asset[0].collection")["slug"] != "mock-punks" { + t.Fatalf("asset[0].collection = %v", first["collection"]) + } + for _, slug := range []string{"mock-punks", "unknown-slug", "mock-apes"} { + want := 5 + if slug != "mock-punks" { + want = 0 // mock-apes has no seeded assets; unknown slugs answer 200 empty + } + if got := len(f.openseaAssets(map[string]string{"collection_slug": slug})); got != want { + t.Fatalf("collection_slug=%s = %d assets, want %d", slug, got, want) + } + } + + // ===== single-asset reads match the address case-insensitively and 404 unknown shapes ===== + params := map[string]string{"chain": "ethereum", "address": openseaPunks, "identifier": "1"} + got := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/"+openseaPunks+"/1", params, nil, nil, openseaKey) + if got.Status != 200 || got.Body["token_id"] != "1" || got.Body["name"] != "Mock Punk #1" { + t.Fatalf("get asset -> %d %v", got.Status, got.Body) + } + if openseaMap(t, got.Body["collection"], "asset.collection")["name"] != "Mock Punks" { + t.Fatalf("asset collection = %v", got.Body["collection"]) + } + // EVM addresses are case-insensitive hex: checksummed casing still hits. + mixed := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/"+strings.ToUpper(openseaPunks)+"/1", + map[string]string{"chain": "ethereum", "address": strings.ToUpper(openseaPunks), "identifier": "1"}, nil, nil, openseaKey) + if mixed.Status != 200 { + t.Fatalf("uppercase address -> %d, want 200", mixed.Status) + } + for name, p := range map[string]map[string]string{ + "unknown address": {"chain": "ethereum", "address": "0x0000000000000000000000000000000000000999", "identifier": "1"}, + "unknown identifier": {"chain": "ethereum", "address": openseaPunks, "identifier": "999"}, + } { + if r := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/x/y/z", p, nil, nil, openseaKey); r.Status != 404 || r.Body["error"] != "Asset not found" { + t.Fatalf("%s -> %d %v, want 404 {error: Asset not found}", name, r.Status, r.Body) + } + } + // The chain segment is ignored (asserted as-is; deviation vs the chain-scoped real API). + if r := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/matic/"+openseaPunks+"/1", + map[string]string{"chain": "matic", "address": openseaPunks, "identifier": "1"}, nil, nil, openseaKey); r.Status != 200 || r.Body["chain"] != "ethereum" { + t.Fatalf("wrong-chain asset read -> %d %v, want the ethereum asset as-is", r.Status, r.Body) + } + + // ===== collections read back contracts and string-typed stats; unknown slugs 404 ===== + coll := f.call("collections", "on_get_collection", "GET", "/api/v2/collections/mock-punks", + map[string]string{"slug": "mock-punks"}, nil, nil, openseaKey) + if coll.Status != 200 || coll.Body["slug"] != "mock-punks" || coll.Body["name"] != "Mock Punks" { + t.Fatalf("get collection -> %d %v", coll.Status, coll.Body) + } + contract := openseaMap(t, openseaList(t, coll.Body["primary_asset_contracts"], "contracts")[0], "contracts[0]") + if contract["address"] != openseaPunks || contract["chain"] != "ethereum" || contract["schema_name"] != "ERC721" { + t.Fatalf("primary_asset_contracts[0] = %v", contract) + } + stats := openseaMap(t, coll.Body["stats"], "stats") + for k, want := range map[string]string{ + "total_supply": "10000", "count": "10000", "num_owners": "5000", + "total_volume": "1000.5", "floor_price": "0.05", + } { + if stats[k] != want { + t.Fatalf("stats.%s = %v (%T), want string %q", k, stats[k], stats[k], want) + } + } + apes := f.call("collections", "on_get_collection", "GET", "/api/v2/collections/mock-apes", + map[string]string{"slug": "mock-apes"}, nil, nil, openseaKey) + if openseaMap(t, apes.Body["stats"], "apes stats")["floor_price"] != "10.5" { + t.Fatalf("mock-apes floor_price = %v", apes.Body["stats"]) + } + if r := f.call("collections", "on_get_collection", "GET", "/api/v2/collections/nope", + map[string]string{"slug": "nope"}, nil, nil, openseaKey); r.Status != 404 || r.Body["error"] != "Collection not found" { + t.Fatalf("unknown collection -> %d %v, want 404 {error: Collection not found}", r.Status, r.Body) + } + + // ===== limit/next cursor pagination walks the pages and 400s a malformed cursor ===== + var walked []string + query := map[string]string{"limit": "2"} + pages := 0 + for { + r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, query, nil, openseaKey) + if r.Status != 200 { + t.Fatalf("page %d -> %d: %v", pages, r.Status, r.Body) + } + page := openseaList(t, r.Body["assets"], "assets page") + if len(page) != 2 && pages < 2 { + t.Fatalf("page %d has %d assets, want 2", pages, len(page)) + } + for _, a := range page { + walked = append(walked, openseaMap(t, a, "asset")["token_id"].(string)) + } + pages++ + next, _ := r.Body["next"].(string) + if next == "" { + break + } + query["next"] = next + } + if strings.Join(walked, ",") != "1,2,3,4,5" || pages != 3 { + t.Fatalf("pagination walked %v over %d pages, want 1,2,3,4,5 over 3", walked, pages) + } + // limit=0 disables paging entirely. + if got := len(f.openseaAssets(map[string]string{"limit": "0"})); got != 5 { + t.Fatalf("limit=0 = %d assets, want all 5 (paging disabled)", got) + } + // A malformed cursor is the adapter's own 400. + if r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, + map[string]string{"limit": "2", "next": "zzz"}, nil, openseaKey); r.Status != 400 || r.Body["error"] != "Invalid cursor parameter." { + t.Fatalf("malformed cursor -> %d %v, want 400 Invalid cursor parameter.", r.Status, r.Body) + } + + // ===== events filter by collection_slug and event_type ===== + ev := f.call("events", "on_list_events", "GET", "/api/v2/events", nil, nil, nil, openseaKey) + if ev.Status != 200 { + t.Fatalf("events -> %d: %v", ev.Status, ev.Body) + } + events := openseaList(t, ev.Body["asset_events"], "asset_events") + if len(events) != 1 { + t.Fatalf("seeded events = %d, want 1", len(events)) + } + e := openseaMap(t, events[0], "asset_events[0]") + if e["event_type"] != "sale" || e["collection_slug"] != "mock-punks" || e["quantity"] != "1" { + t.Fatalf("event = %v", e) + } + if openseaMap(t, e["asset"], "event.asset")["token_id"] != "1" { + t.Fatalf("event asset = %v", e["asset"]) + } + if openseaMap(t, e["from_account"], "from_account")["address"] != openseaMaker || + openseaMap(t, e["to_account"], "to_account")["address"] != openseaOfferer { + t.Fatalf("event accounts = %v -> %v", e["from_account"], e["to_account"]) + } + pay := openseaMap(t, e["payment"], "payment") + if pay["quantity"] != openseaWei05 || pay["decimals"] != "18" { + t.Fatalf("event payment = %v", pay) + } + if r := f.call("events", "on_list_events", "GET", "/api/v2/events", nil, + map[string]string{"event_type": "sale"}, nil, openseaKey); len(openseaList(t, r.Body["asset_events"], "sale events")) != 1 { + t.Fatalf("event_type=sale = %v", r.Body) + } + if r := f.call("events", "on_list_events", "GET", "/api/v2/events", nil, + map[string]string{"event_type": "offer"}, nil, openseaKey); len(openseaList(t, r.Body["asset_events"], "offer events")) != 0 { + t.Fatalf("event_type=offer = %v, want empty", r.Body) + } + if r := f.call("events", "on_list_events", "GET", "/api/v2/events", nil, + map[string]string{"collection_slug": "nope"}, nil, openseaKey); len(openseaList(t, r.Body["asset_events"], "unknown slug events")) != 0 { + t.Fatalf("collection_slug=nope = %v, want empty", r.Body) + } +} + +func TestOpenSeaSeaportOrders(t *testing.T) { + f := newOpenseaFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + orderParams := map[string]string{"chain": "ethereum", "protocol": "seaport"} + + // ===== listings carry the Seaport ask shape: the NFT in offer, payment in consideration ===== + lr := f.call("orders", "on_list_listings", "GET", "/api/v2/orders/ethereum/seaport/listings", orderParams, nil, nil, openseaKey) + if lr.Status != 200 { + t.Fatalf("listings -> %d: %v", lr.Status, lr.Body) + } + listings := openseaList(t, lr.Body["orders"], "listings") + if len(listings) != 1 { + t.Fatalf("seeded listings = %d, want 1", len(listings)) + } + l := openseaMap(t, listings[0], "listing") + listingHash := openseaHash32(t, l["order_hash"], "listing order_hash") + if l["protocol_address"] != openseaSeaport || l["chain"] != "ethereum" || l["side"] != "ask" { + t.Fatalf("listing envelope fields = %v", l) + } + if l["maker"] != openseaMaker || l["taker"] != nil || l["current_price"] != openseaWei05 { + t.Fatalf("listing maker/taker/price = %v", l) + } + if _, has := l["id"]; has { + t.Fatalf("listed order leaks the internal store id: %v", l["id"]) + } + lp := openseaMap(t, l["parameters"], "listing parameters") + if lp["offerer"] != openseaMaker || lp["zone"] != openseaZero || lp["startTime"] != "1700000000" || lp["endTime"] != "1700086400" { + t.Fatalf("listing parameters scalars = %v", lp) + } + if zoneHash := openseaHash32(t, lp["zone_hash"], "listing zone_hash"); zoneHash != "0x"+strings.Repeat("0", 64) { + t.Fatalf("listing zone_hash = %s, want the zero bytes32", zoneHash) + } + if lp["totalOriginalConsiderationItems"] != "1" || lp["counter"] != "0" { + t.Fatalf("listing counter fields = %v", lp) + } + openseaHash32(t, lp["salt"], "listing salt") + if sig, _ := l["signature"].(string); !strings.HasPrefix(sig, "0x") || !strings.HasSuffix(sig, "1b") { + t.Fatalf("listing signature = %v, want 0x-prefixed ending in the 1b v-byte", l["signature"]) + } + // Ask side: the ERC721 is offered... + offer := openseaMap(t, openseaList(t, lp["offer"], "listing offer")[0], "listing offer[0]") + if toFloat(t, offer["itemType"]) != 2 || offer["token"] != openseaPunks || offer["identifierOrCriteria"] != "1" || + offer["startAmount"] != "1" || offer["endAmount"] != "1" { + t.Fatalf("listing offer[0] = %v, want the ERC721 item", offer) + } + // ...for native payment to the offerer. + cons := openseaMap(t, openseaList(t, lp["consideration"], "listing consideration")[0], "listing consideration[0]") + if toFloat(t, cons["itemType"]) != 0 || cons["token"] != openseaZero || + cons["startAmount"] != openseaWei05 || cons["endAmount"] != openseaWei05 || cons["recipient"] != openseaMaker { + t.Fatalf("listing consideration[0] = %v, want the 0.05 ETH native item", cons) + } + // chain/protocol path segments are ignored (asserted as-is; deviation vs + // the chain-scoped real route). + if r := f.call("orders", "on_list_listings", "GET", "/api/v2/orders/matic/whatever/listings", + map[string]string{"chain": "matic", "protocol": "whatever"}, nil, nil, openseaKey); r.Status != 200 || + openseaMap(t, openseaList(t, r.Body["orders"], "orders")[0], "order")["chain"] != "ethereum" { + t.Fatalf("wrong-chain listings -> %d %v, want the ethereum orders as-is", r.Status, r.Body) + } + + // ===== offers invert the shape: payment in offer, the NFT in consideration ===== + or := f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers", orderParams, nil, nil, openseaKey) + if or.Status != 200 { + t.Fatalf("offers -> %d: %v", or.Status, or.Body) + } + offers := openseaList(t, or.Body["orders"], "offers") + if len(offers) != 1 { + t.Fatalf("seeded offers = %d, want 1", len(offers)) + } + o := openseaMap(t, offers[0], "offer") + offerHash := openseaHash32(t, o["order_hash"], "offer order_hash") + if offerHash == listingHash || o["side"] != "bid" || o["maker"] != openseaOfferer || o["current_price"] != openseaWei03 { + t.Fatalf("offer envelope = %v", o) + } + op := openseaMap(t, o["parameters"], "offer parameters") + payItem := openseaMap(t, openseaList(t, op["offer"], "offer offer")[0], "offer offer[0]") + if toFloat(t, payItem["itemType"]) != 0 || payItem["startAmount"] != openseaWei03 || payItem["endAmount"] != openseaWei03 { + t.Fatalf("offer offer[0] = %v, want the 0.03 ETH native payment", payItem) + } + nftItem := openseaMap(t, openseaList(t, op["consideration"], "offer consideration")[0], "offer consideration[0]") + if toFloat(t, nftItem["itemType"]) != 2 || nftItem["token"] != openseaPunks || nftItem["identifierOrCriteria"] != "1" || + nftItem["recipient"] != openseaOfferer { + t.Fatalf("offer consideration[0] = %v, want the ERC721 item", nftItem) + } + + // ===== created offers are stateful, deterministic, and defaulted ===== + created := f.call("orders", "on_create_offer", "POST", "/api/v2/offers", nil, nil, map[string]any{ + "criteria": map[string]any{ + "data": map[string]any{"token": openseaPunks, "identifier": "3"}, + }, + "maker": "0x0000000000000000000000000000000000000009", + "consideration": map[string]any{"price": "7770000000000000"}, + }, openseaKey) + if created.Status != 200 { + t.Fatalf("create offer -> %d: %v", created.Status, created.Body) + } + createdHash := openseaHash32(t, created.Body["order_hash"], "created order_hash") + if created.Body["protocol_address"] != openseaSeaport || created.Body["chain"] != "ethereum" { + t.Fatalf("create offer response = %v", created.Body) + } + // STATEFUL: the created order joins the offers list with its parameters. + after := openseaList(t, f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers", + orderParams, nil, nil, openseaKey).Body["orders"], "offers after create") + var createdOrder map[string]any + for _, oo := range after { + m := openseaMap(t, oo, "offer") + if m["order_hash"] == createdHash { + createdOrder = m + } + } + if createdOrder == nil { + t.Fatalf("created offer %s missing from the offers list", createdHash) + } + cp := openseaMap(t, createdOrder["parameters"], "created parameters") + if cp["offerer"] != "0x0000000000000000000000000000000000000009" { + t.Fatalf("created offerer = %v, want the posted maker", cp["offerer"]) + } + if item := openseaMap(t, openseaList(t, cp["offer"], "created offer")[0], "created offer[0]"); item["startAmount"] != "7770000000000000" { + t.Fatalf("created payment = %v", item) + } + if item := openseaMap(t, openseaList(t, cp["consideration"], "created consideration")[0], "created consideration[0]"); item["identifierOrCriteria"] != "3" { + t.Fatalf("created NFT = %v", item) + } + // Deterministic: the same parameters mint the same hash — and a second + // create inserts a duplicate (no dedupe by order_hash; asserted as-is, + // the real API would not return duplicate orders). + again := f.call("orders", "on_create_offer", "POST", "/api/v2/offers", nil, nil, map[string]any{ + "criteria": map[string]any{ + "data": map[string]any{"token": openseaPunks, "identifier": "3"}, + }, + "maker": "0x0000000000000000000000000000000000000009", + "consideration": map[string]any{"price": "7770000000000000"}, + }, openseaKey) + if again.Body["order_hash"] != createdHash { + t.Fatalf("re-created hash = %v, want the deterministic %s", again.Body["order_hash"], createdHash) + } + final := openseaList(t, f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers", + orderParams, nil, nil, openseaKey).Body["orders"], "offers final") + dupes := 0 + for _, oo := range final { + if openseaMap(t, oo, "offer")["order_hash"] == createdHash { + dupes++ + } + } + if len(final) != 3 || dupes != 2 { + t.Fatalf("offers after re-create = %d total, %d with the created hash; want 3 total, 2 (seeded + duplicated)", len(final), dupes) + } + // Defaults when the body omits everything: the mock-punks token, id 1, + // maker 0x…03, and a 0.01 ETH price. + def := f.call("orders", "on_create_offer", "POST", "/api/v2/offers", nil, nil, map[string]any{}, openseaKey) + if def.Status != 200 { + t.Fatalf("empty create -> %d: %v", def.Status, def.Body) + } + var defaulted map[string]any + for _, oo := range openseaList(t, f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers", + orderParams, nil, nil, openseaKey).Body["orders"], "offers with default") { + if m := openseaMap(t, oo, "offer"); m["order_hash"] == def.Body["order_hash"] { + defaulted = m + } + } + if defaulted == nil { + t.Fatalf("defaulted create %v missing from the offers list", def.Body["order_hash"]) + } + dp := openseaMap(t, defaulted["parameters"], "defaulted parameters") + if dp["offerer"] != "0x0000000000000000000000000000000000000003" { + t.Fatalf("defaulted maker = %v", dp["offerer"]) + } + if item := openseaMap(t, openseaList(t, dp["offer"], "defaulted offer")[0], "defaulted offer[0]"); item["startAmount"] != "10000000000000000" { + t.Fatalf("defaulted price = %v", item) + } + if item := openseaMap(t, openseaList(t, dp["consideration"], "defaulted consideration")[0], "defaulted consideration[0]"); item["identifierOrCriteria"] != "1" || item["token"] != openseaPunks { + t.Fatalf("defaulted NFT = %v", item) + } +} diff --git a/adapters/persona-style/scripts/inquiries.star b/adapters/persona-style/scripts/inquiries.star index d3ef8a26..6d313ed4 100644 --- a/adapters/persona-style/scripts/inquiries.star +++ b/adapters/persona-style/scripts/inquiries.star @@ -74,9 +74,12 @@ def _advance_inquiry(inquiry_id): new_status = _derive_inquiry_status(doc) if new_status != doc["status"]: doc["status"] = new_status - ic.update(inquiry_id, doc) already_terminal = doc.get("_ever_terminal", False) - doc["_ever_terminal"] = True + # Stamp _ever_terminal on terminal transitions ONLY: the created -> + # pending hop must not spend the inquiry's one webhook (real Persona + # notifies the terminal outcome whether or not the client polled). + if new_status == "completed" or new_status == "declined": + doc["_ever_terminal"] = True ic.update(inquiry_id, doc) if new_status == "completed": _seed_verifications(inquiry_id, doc["reference_id"]) diff --git a/adapters/persona_style_test.go b/adapters/persona_style_test.go new file mode 100644 index 00000000..cb72aa48 --- /dev/null +++ b/adapters/persona_style_test.go @@ -0,0 +1,629 @@ +package adapters + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the persona-style adapter scripts directly (lib.star preloaded) +// over a shared store, virtual clock and webhook sink: the Bearer gate and +// the /api/inquiry/v1 JSON:API shapes, the clock-derived inquiry lifecycle +// (created -> pending -> completed | declined) with its auto-seeded +// verifications and resume clock-restart, and the Persona-Signature HMAC +// scheme in both directions (inbound receiver verification + signed +// outbound deliveries). +const ( + personaAuth = "Bearer persona_stunt_test_key" + personaHost = "withpersona.test" + personaWebhookSecret = "stunt_persona_mock_signing_key" +) + +// personaIDRE / personaVerRE pin the synthetic id shapes: inq_/ver_ + a +// zero-padded 6-digit sequence. +var ( + personaIDRE = regexp.MustCompile(`^inq_\d{6}$`) + personaVerRE = regexp.MustCompile(`^ver_\d{6}$`) +) + +type personaFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + emitter *events.Emitter + host string +} + +func newPersonaFixture(t *testing.T, start time.Time) *personaFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "persona-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + em := events.NewEmitter() + t.Cleanup(em.Close) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em, + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &personaFixture{t: t, vc: vc, vms: map[string]*starlark.VM{ + "inquiries": load("inquiries.star"), "hooks": load("webhooks.star"), + }, emitter: em, host: personaHost} +} + +// call invokes handler on the named script VM; auth is the full Authorization +// header value ("" = header absent). +func (f *personaFixture) call(group, handler, method, path string, params map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// personaCreate posts an inquiry create and returns the minted id. +func (f *personaFixture) personaCreate(body map[string]any) string { + f.t.Helper() + r := f.call("inquiries", "on_create_inquiry", "POST", "/api/inquiry/v1/inquiries", nil, body, personaAuth) + if r.Status != 201 { + f.t.Fatalf("create inquiry -> %d: %v", r.Status, r.Body) + } + return personaDataID(f.t, r) +} + +// personaGet reads GET /api/inquiry/v1/inquiries/{id}. +func (f *personaFixture) personaGet(id string) starlark.Response { + f.t.Helper() + return f.call("inquiries", "on_get_inquiry", "GET", "/api/inquiry/v1/inquiries/"+id, + map[string]string{"inquiry_id": id}, nil, personaAuth) +} + +// personaStatus returns the inquiry's current derived status. +func (f *personaFixture) personaStatus(id string) string { + f.t.Helper() + r := f.personaGet(id) + if r.Status != 200 { + f.t.Fatalf("get inquiry %s -> %d: %v", id, r.Status, r.Body) + } + return personaAttrs(f.t, r)["status"].(string) +} + +// personaVerifications reads GET /api/inquiry/v1/inquiries/{id}/verifications. +func (f *personaFixture) personaVerifications(id string) starlark.Response { + f.t.Helper() + return f.call("inquiries", "on_get_verifications", "GET", "/api/inquiry/v1/inquiries/"+id+"/verifications", + map[string]string{"inquiry_id": id}, nil, personaAuth) +} + +// personaDataID extracts data.id from a JSON:API response. +func personaDataID(t *testing.T, r starlark.Response) string { + t.Helper() + data, ok := r.Body["data"].(map[string]any) + if !ok { + t.Fatalf("response %d data = %v, want the JSON:API data object", r.Status, r.Body["data"]) + } + id, _ := data["id"].(string) + return id +} + +// personaAttrs extracts data.attributes from a JSON:API response. +func personaAttrs(t *testing.T, r starlark.Response) map[string]any { + t.Helper() + data, ok := r.Body["data"].(map[string]any) + if !ok { + t.Fatalf("response %d data = %v, want the JSON:API data object", r.Status, r.Body["data"]) + } + attrs, ok := data["attributes"].(map[string]any) + if !ok { + t.Fatalf("response data.attributes = %v, want an object", data["attributes"]) + } + return attrs +} + +// personaErr returns errors[0] from a JSON:API error envelope. +func personaErr(t *testing.T, r starlark.Response) map[string]any { + t.Helper() + errs, ok := r.Body["errors"].([]any) + if !ok || len(errs) == 0 { + t.Fatalf("response %d errors = %v, want a JSON:API error envelope", r.Status, r.Body) + } + e, ok := errs[0].(map[string]any) + if !ok { + t.Fatalf("errors[0] = %v, want an object", errs[0]) + } + return e +} + +// personaSign builds a Persona-Signature header over the exact bytes: +// t=,v1=. +func personaSign(t string, raw []byte) string { + mac := hmac.New(sha256.New, []byte(personaWebhookSecret)) + mac.Write([]byte(t + "." + string(raw))) + return "t=" + t + ",v1=" + hex.EncodeToString(mac.Sum(nil)) +} + +// personaDelivery is one webhook POST captured by the sink. +type personaDelivery struct { + body []byte + sig string +} + +// envelope returns the delivery's parsed {type, payload} envelope. +func (d personaDelivery) envelope(t *testing.T) (string, map[string]any) { + t.Helper() + var env struct { + Type string `json:"type"` + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(d.body, &env); err != nil { + t.Fatalf("webhook body %s unparsable: %v", d.body, err) + } + if env.Type == "" || env.Payload == nil { + t.Fatalf("webhook body %s, want a {type, payload} envelope", d.body) + } + return env.Type, env.Payload +} + +// captureWebhooks registers a sink and returns a collector over the raw +// deliveries (body + Persona-Signature header). +func (f *personaFixture) captureWebhooks() func() []personaDelivery { + f.t.Helper() + var mu sync.Mutex + var got []personaDelivery + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + got = append(got, personaDelivery{body: b, sig: r.Header.Get("Persona-Signature")}) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + f.t.Cleanup(sink.Close) + f.emitter.Register("test", sink.URL) + return func() []personaDelivery { + mu.Lock() + defer mu.Unlock() + return append([]personaDelivery(nil), got...) + } +} + +// personaVerifyDelivery checks Persona-Signature against Persona's scheme — +// v1 == hex(HMAC-SHA256(secret, t + "." + raw_body)) — over the exact bytes +// the sink received, with t taken verbatim from the header. +func personaVerifyDelivery(t *testing.T, d personaDelivery) { + t.Helper() + var ts, v1 string + for _, part := range strings.Split(d.sig, ",") { + switch { + case strings.HasPrefix(part, "t="): + ts = strings.TrimPrefix(part, "t=") + case strings.HasPrefix(part, "v1="): + v1 = strings.TrimPrefix(part, "v1=") + } + } + if ts == "" || v1 == "" { + t.Fatalf("Persona-Signature = %q, want t= and v1= components", d.sig) + } + mac := hmac.New(sha256.New, []byte(personaWebhookSecret)) + mac.Write([]byte(ts + "." + string(d.body))) + want := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(v1)) { + t.Fatalf("Persona-Signature v1 = %q, want %q over body %s", v1, want, d.body) + } +} + +// TestPersonaInquiryCreateAndAuthGate: the Bearer gate's JSON:API 401s, the +// create envelope with its zero-padded inq_ sequence, and the required-field +// validation. +func TestPersonaInquiryCreateAndAuthGate(t *testing.T) { + f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC()) + create := func(body map[string]any, auth string) starlark.Response { + return f.call("inquiries", "on_create_inquiry", "POST", "/api/inquiry/v1/inquiries", nil, body, auth) + } + + // ===== a missing, bare or wrong-scheme token is a 401 in the JSON:API error envelope ===== + // Persona gates every endpoint behind a Bearer API key. + for _, auth := range []string{"", "persona_stunt_test_key", "Basic abc"} { + r := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}, auth) + if r.Status != 401 { + t.Fatalf("auth %q -> %d, want 401", auth, r.Status) + } + if e := personaErr(t, r); e["status"] != "401" || e["code"] != "unauthorized" { + t.Fatalf("401 envelope = %v, want status 401 / unauthorized", e) + } + } + + // ===== a create mints a zero-padded inq_ id in the JSON:API envelope ===== + // Attributes are snake_case (as-is): real Persona serializes JSON:API + // attributes in kebab-case (reference-id, created-at). + r := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}, personaAuth) + if r.Status != 201 { + t.Fatalf("create inquiry -> %d: %v", r.Status, r.Body) + } + id := personaDataID(t, r) + if !personaIDRE.MatchString(id) || id != "inq_000001" { + t.Fatalf("first inquiry id = %q, want inq_000001 (fresh store)", id) + } + attrs := personaAttrs(t, r) + if attrs["status"] != "created" || attrs["reference_id"] != "user-42" || + attrs["template_id"] != "itmpl_abc123" { + t.Fatalf("create attributes = %v, want created echoing the payload", attrs) + } + createdAt, _ := attrs["created_at"].(string) + if _, err := time.Parse(time.RFC3339, createdAt); err != nil { + t.Fatalf("created_at = %q, want an RFC3339 timestamp", createdAt) + } + + // ===== sequential creates advance the id sequence ===== + second := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-43"}, personaAuth) + if got := personaDataID(t, second); got != "inq_000002" { + t.Fatalf("second inquiry id = %q, want inq_000002", got) + } + + // ===== any Bearer is accepted: the gate checks presence, not a store (as-is) ===== + // Real Persona validates the API key; any non-empty Bearer mints here. + if r := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-44"}, + "Bearer totally-unknown-key"); r.Status != 201 { + t.Fatalf("any bearer -> %d, want 201 (presence-only gate)", r.Status) + } + + // ===== a create missing template_id or reference_id is a 400 invalid_request ===== + for name, body := range map[string]map[string]any{ + "no template": {"reference_id": "user-42"}, + "no reference": {"template_id": "itmpl_abc123"}, + "empty body": {}, + "missing body": nil, + } { + r := create(body, personaAuth) + if r.Status != 400 { + t.Fatalf("create %s -> %d, want 400", name, r.Status) + } + if e := personaErr(t, r); e["code"] != "invalid_request" { + t.Fatalf("create %s error = %v, want invalid_request", name, e) + } + } +} + +// TestPersonaInquiryLifecycleStates: the derive-on-read status machine on the +// virtual clock — created -> pending -> completed (persisted), declined via +// the simulate_fail simulator extension, resume restarting the clock without +// duplicating verifications, and the 404s. +func TestPersonaInquiryLifecycleStates(t *testing.T) { + f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC()) + + // ===== the status derives from the clock created to pending to completed ===== + // created (0-1s), pending (1-3s), completed (+3s); reads persist transitions. + id := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}) + if s := f.personaStatus(id); s != "created" { + t.Fatalf("fresh status = %q, want created", s) + } + f.vc.Advance(2 * time.Second) + if s := f.personaStatus(id); s != "pending" { + t.Fatalf("status at +2s = %q, want pending", s) + } + f.vc.Advance(2 * time.Second) + if s := f.personaStatus(id); s != "completed" { + t.Fatalf("status at +4s = %q, want completed", s) + } + if s := f.personaStatus(id); s != "completed" { + t.Fatalf("re-read status = %q, want completed (transition persisted)", s) + } + ver := f.personaVerifications(id) + if ver.Status != 200 { + t.Fatalf("verifications -> %d: %v", ver.Status, ver.Body) + } + if data, ok := ver.Body["data"].([]any); !ok || len(data) != 2 { + t.Fatalf("completed verifications = %v, want the seeded pair", ver.Body["data"]) + } + + // ===== resume restarts the clock at pending without duplicating verifications ===== + // Resume answers pending immediately and re-completes 3s later; the + // already-seeded verifications are not duplicated. + res := f.call("inquiries", "on_resume_inquiry", "POST", "/api/inquiry/v1/inquiries/"+id+"/resume", + map[string]string{"inquiry_id": id}, nil, personaAuth) + if res.Status != 200 { + t.Fatalf("resume -> %d: %v", res.Status, res.Body) + } + if rAttrs := personaAttrs(t, res); rAttrs["status"] != "pending" || rAttrs["reference_id"] != "user-42" { + t.Fatalf("resume attributes = %v, want pending + the reference echoed", rAttrs) + } + f.vc.Advance(4 * time.Second) + if s := f.personaStatus(id); s != "completed" { + t.Fatalf("status after resume +4s = %q, want completed again", s) + } + if ver := f.personaVerifications(id); ver.Status != 200 { + t.Fatalf("verifications after resume -> %d", ver.Status) + } else if data, _ := ver.Body["data"].([]any); len(data) != 2 { + t.Fatalf("verifications after resume = %d entries, want still 2 (no dupes)", len(data)) + } + + // ===== simulate_fail declines at the terminal transition and seeds nothing ===== + // Stunt-only flag: the review outcome is declined, not completed. + failID := f.personaCreate(map[string]any{ + "template_id": "itmpl_abc123", "reference_id": "user-43", "simulate_fail": true, + }) + f.vc.Advance(4 * time.Second) + if s := f.personaStatus(failID); s != "declined" { + t.Fatalf("simulate_fail status = %q, want declined", s) + } + if ver := f.personaVerifications(failID); ver.Status != 200 { + t.Fatalf("declined verifications -> %d", ver.Status) + } else if data, _ := ver.Body["data"].([]any); len(data) != 0 { + t.Fatalf("declined verifications = %v, want none seeded", data) + } + + // ===== unknown inquiries are JSON:API 404s on every parameterized route ===== + for _, rt := range []struct { + handler string + path string + method string + }{ + {"on_get_inquiry", "/api/inquiry/v1/inquiries/inq_nope", "GET"}, + {"on_resume_inquiry", "/api/inquiry/v1/inquiries/inq_nope/resume", "POST"}, + {"on_get_verifications", "/api/inquiry/v1/inquiries/inq_nope/verifications", "GET"}, + } { + r := f.call("inquiries", rt.handler, rt.method, rt.path, + map[string]string{"inquiry_id": "inq_nope"}, nil, personaAuth) + if r.Status != 404 { + t.Fatalf("%s %s -> %d, want 404", rt.method, rt.path, r.Status) + } + if e := personaErr(t, r); e["code"] != "not_found" { + t.Fatalf("%s 404 envelope = %v, want not_found", rt.path, e) + } + } +} + +// TestPersonaVerificationsShape: the government-id + selfie pair auto-seeded +// at the completion transition, its JSON:API list shape, and that nothing is +// seeded before the terminal state. +func TestPersonaVerificationsShape(t *testing.T) { + f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC()) + + // ===== verifications are empty until the terminal transition fires ===== + // The lifecycle is derive-on-read: side effects land only when a read + // observes the terminal state. + earlyID := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}) + f.vc.Advance(2 * time.Second) + if s := f.personaStatus(earlyID); s != "pending" { + t.Fatalf("early status = %q, want pending", s) + } + if ver := f.personaVerifications(earlyID); ver.Status != 200 { + t.Fatalf("pending verifications -> %d", ver.Status) + } else if data, _ := ver.Body["data"].([]any); len(data) != 0 { + t.Fatalf("pending verifications = %v, want none yet", data) + } + + // ===== completion seeds the government-id and selfie verifications ===== + id := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}) + f.vc.Advance(4 * time.Second) + if s := f.personaStatus(id); s != "completed" { + t.Fatalf("status = %q, want completed", s) + } + ver := f.personaVerifications(id) + if ver.Status != 200 { + t.Fatalf("verifications -> %d: %v", ver.Status, ver.Body) + } + data, ok := ver.Body["data"].([]any) + if !ok || len(data) != 2 { + t.Fatalf("verifications data = %v, want exactly two entries", ver.Body["data"]) + } + wantNames := []string{"government-id", "selfie"} + for i, item := range data { + v, ok := item.(map[string]any) + if !ok || v["type"] != "verification" { + t.Fatalf("verification[%d] = %v, want type verification", i, item) + } + if !personaVerRE.MatchString(v["id"].(string)) { + t.Fatalf("verification[%d] id = %v, want the ver_ padded shape", i, v["id"]) + } + attrs, _ := v["attributes"].(map[string]any) + if attrs["name"] != wantNames[i] || attrs["status"] != "completed" || attrs["result"] != "pass" { + t.Fatalf("verification[%d] attributes = %v, want %s completed pass", i, attrs, wantNames[i]) + } + if _, err := time.Parse(time.RFC3339, attrs["created_at"].(string)); err != nil { + t.Fatalf("verification[%d] created_at = %v, want RFC3339", i, attrs["created_at"]) + } + } +} + +// TestPersonaWebhookReceiver: the inbound Persona-Signature flow — fresh +// correctly-MACed deliveries are accepted, tampering and replay are 401s in +// the JSON:API error envelope. +func TestPersonaWebhookReceiver(t *testing.T) { + f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC()) + raw := `{"type":"inquiry.completed","data":{"id":"inq_000001"}}` + post := func(sig, body string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if sig != "" { + headers["Persona-Signature"] = sig + } + resp, err := f.vms["hooks"].Call("on_webhook", starlark.Request{ + Method: "POST", Path: "/api/inquiry/v1/webhooks", Host: f.host, Headers: headers, RawBody: body, + }) + if err != nil { + f.t.Fatalf("on_webhook: %v", err) + } + return resp + } + unixNow := func() string { + return strconv.FormatInt(f.vc.Now().Unix(), 10) + } + + // ===== a fresh correctly-signed webhook is accepted ===== + // v1 = HMAC over t + "." + the exact request bytes. + r := post(personaSign(unixNow(), []byte(raw)), raw) + if r.Status != 200 || r.Body["received"] != true { + t.Fatalf("signed webhook -> %d %v, want 200 {received:true}", r.Status, r.Body) + } + + // ===== a tampered body or wrong MAC is a 401 invalid_signature ===== + // The header MACs different bytes than the ones on the wire. + if r := post(personaSign(unixNow(), []byte(raw)), raw+" "); r.Status != 401 { + t.Fatalf("tampered body -> %d, want 401", r.Status) + } else if e := personaErr(t, r); e["code"] != "invalid_signature" { + t.Fatalf("tampered body envelope = %v, want invalid_signature", e) + } + // A structurally valid header whose MAC matches nothing. + good := personaSign(unixNow(), []byte(raw)) + badMAC := good[:strings.Index(good, ",v1=")+4] + strings.Repeat("0", 64) + if r := post(badMAC, raw); r.Status != 401 { + t.Fatalf("zeroed MAC -> %d, want 401", r.Status) + } else if e := personaErr(t, r); e["code"] != "invalid_signature" { + t.Fatalf("zeroed MAC envelope = %v, want invalid_signature", e) + } + + // ===== a stale or far-future t is a 401 invalid_timestamp ===== + // Replay protection: |now - t| must stay within 5 minutes. + stale := unixNow() + f.vc.Advance(6 * time.Minute) + if r := post(personaSign(stale, []byte(raw)), raw); r.Status != 401 { + t.Fatalf("stale t -> %d, want 401", r.Status) + } else if e := personaErr(t, r); e["code"] != "invalid_timestamp" { + t.Fatalf("stale t envelope = %v, want invalid_timestamp", e) + } + future := strconv.FormatInt(f.vc.Now().Add(6*time.Minute).Unix(), 10) + if r := post(personaSign(future, []byte(raw)), raw); r.Status != 401 { + t.Fatalf("future t -> %d, want 401", r.Status) + } else if e := personaErr(t, r); e["code"] != "invalid_timestamp" { + t.Fatalf("future t envelope = %v, want invalid_timestamp", e) + } + + // ===== a missing header or unparseable signature is a 401 ===== + // The signature IS the auth on this endpoint. + if r := post("", "{}"); r.Status != 401 { + t.Fatalf("missing header -> %d, want 401", r.Status) + } else if e := personaErr(t, r); e["code"] != "missing_signature" { + t.Fatalf("missing header envelope = %v, want missing_signature", e) + } + for _, sig := range []string{"garbage", "t=" + unixNow() + ",v1=", "t=notadigit,v1=abc"} { + if r := post(sig, raw); r.Status != 401 { + t.Fatalf("header %q -> %d, want 401", sig, r.Status) + } else if e := personaErr(t, r); e["code"] != "invalid_signature" { + t.Fatalf("header %q envelope = %v, want invalid_signature", sig, e) + } + } +} + +// TestPersonaOutboundSignedWebhooks: the emitter side — exactly one signed +// inquiry.completed at the terminal transition (even when the client polled +// through pending), no re-emit on re-reads or post-resume re-completion, and +// inquiry.declined for failing inquiries. +func TestPersonaOutboundSignedWebhooks(t *testing.T) { + f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC()) + delivered := f.captureWebhooks() + + // ===== polling through pending still emits exactly one inquiry.completed ===== + // Persona notifies the terminal outcome regardless of polling; each + // terminal transition fires its webhook exactly once. + id := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}) + f.vc.Advance(2 * time.Second) + if s := f.personaStatus(id); s != "pending" { + t.Fatalf("status at +2s = %q, want pending", s) + } + f.vc.Advance(2 * time.Second) + if s := f.personaStatus(id); s != "completed" { + t.Fatalf("status at +4s = %q, want completed", s) + } + all := delivered() + if len(all) != 1 { + t.Fatalf("%d deliveries after completion, want exactly 1", len(all)) + } + personaVerifyDelivery(t, all[0]) + eventType, payload := all[0].envelope(t) + if eventType != "inquiry.completed" { + t.Fatalf("webhook type = %q, want inquiry.completed", eventType) + } + pData, _ := payload["data"].(map[string]any) + if pData == nil || pData["id"] != id || pData["type"] != "inquiry" { + t.Fatalf("webhook data = %v, want the completed inquiry resource", payload["data"]) + } + pAttrs, _ := pData["attributes"].(map[string]any) + if pAttrs["status"] != "completed" || pAttrs["reference_id"] != "user-42" { + t.Fatalf("webhook attributes = %v, want completed + the reference", pAttrs) + } + + // ===== re-reads and post-resume re-completions do not re-emit ===== + // Resume restarts the clock, but the inquiry keeps its one webhook. + f.personaStatus(id) + res := f.call("inquiries", "on_resume_inquiry", "POST", "/api/inquiry/v1/inquiries/"+id+"/resume", + map[string]string{"inquiry_id": id}, nil, personaAuth) + if res.Status != 200 || personaAttrs(t, res)["status"] != "pending" { + t.Fatalf("resume -> %d %v, want 200 pending", res.Status, res.Body) + } + f.vc.Advance(4 * time.Second) + if s := f.personaStatus(id); s != "completed" { + t.Fatalf("status after resume = %q, want completed", s) + } + if n := len(delivered()); n != 1 { + t.Fatalf("%d deliveries after re-completion, want still 1 (emit exactly once)", n) + } + + // ===== a declined inquiry emits inquiry.declined signed the same way ===== + failID := f.personaCreate(map[string]any{ + "template_id": "itmpl_abc123", "reference_id": "user-43", "simulate_fail": true, + }) + f.vc.Advance(4 * time.Second) + if s := f.personaStatus(failID); s != "declined" { + t.Fatalf("simulate_fail status = %q, want declined", s) + } + all = delivered() + if len(all) != 2 { + t.Fatalf("%d deliveries after the declined transition, want 2", len(all)) + } + personaVerifyDelivery(t, all[1]) + eventType, payload = all[1].envelope(t) + if eventType != "inquiry.declined" { + t.Fatalf("webhook type = %q, want inquiry.declined", eventType) + } + if d, _ := payload["data"].(map[string]any); d == nil || d["id"] != failID { + t.Fatalf("declined webhook data = %v, want the failing inquiry", payload["data"]) + } +} diff --git a/adapters/pinata-style/README.md b/adapters/pinata-style/README.md index aebce3ac..edc2e6fe 100644 --- a/adapters/pinata-style/README.md +++ b/adapters/pinata-style/README.md @@ -47,20 +47,27 @@ Requests without auth return `401`. | Method | Route | Description | |--------|-------|-------------| -| POST | `/pinning/pinFileToIPFS` | Pin a file (multipart upload) → CID | +| POST | `/pinning/pinFileToIPFS` | Pin a file (multipart upload, file part required) → CID | | POST | `/pinning/pinJSONToIPFS` | Pin a JSON object → CID | | DELETE | `/pinning/unpin/{cid}` | Unpin by CID | -| GET | `/data/pinList` | List pins (params: `hashContains`, `pinStart`/`pinEnd`, `pinSizeMin`/`pinSizeMax`, `status`, `metadata` name, `pageLimit`/`pageOffset`) | +| GET | `/data/pinList` | List pins (params: `hashContains`, `pinStart`/`pinEnd`, `pinSizeMin`/`pinSizeMax`, `status`, `metadata` name, `pageLimit` default 10, `pageOffset`) | | GET | `/data/testAuthentication` | Verify auth | -| GET | `/data/pinByHash` | Lookup pin by hash | +| GET | `/data/pinByHash` | Lookup pin by hash (`hash` query param required) | ## Stateful behavior -Pins are stored in a local collection. `pinList` shows all previously pinned -CIDs and honors the real pinList filters (`hashContains`, `pinStart`/`pinEnd` -date range, `pinSizeMin`/`pinSizeMax`, `status`, `metadata` name) plus -`pageLimit`/`pageOffset` paging, applied before slicing with `count` still -reflecting the filtered total. Unpinning removes them. +Pins are stored in a local collection. CIDs are real CIDv0 values — base58 of +the sha2-256 multihash of the pinned bytes (the compact, key-sorted JSON +serialization for `pinJSONToIPFS`, the file part's bytes for +`pinFileToIPFS`) — so pinning identical content returns the same `IpfsHash` +with `isDuplicate: true` and adds no row. `PinSize` is that content's byte +length; `Timestamp`/`date_pinned` are the request time. + +`pinList` shows all previously pinned CIDs and honors the real pinList +filters (`hashContains`, `pinStart`/`pinEnd` date range, +`pinSizeMin`/`pinSizeMax`, `status`, `metadata` name) plus +`pageLimit`/`pageOffset` paging at the real default of 10 rows, with `count` +still reflecting the filtered total before slicing. Unpinning removes them. ## Response shapes @@ -68,8 +75,8 @@ reflecting the filtered total. Unpinning removes them. // Pin result (pinFileToIPFS / pinJSONToIPFS) { "IpfsHash": "Qm...", - "PinSize": 1024, - "Timestamp": "2024-06-15T12:30:00.000Z", + "PinSize": 17, + "Timestamp": "2026-02-03T12:00:00.000Z", "isDuplicate": false } @@ -79,8 +86,8 @@ reflecting the filtered total. Unpinning removes them. "rows": [{ "id": "7000000001", "ipfs_pin_hash": "Qm...", - "size": 1024, - "date_pinned": "2024-06-15T12:30:00.000Z", + "size": 17, + "date_pinned": "2026-02-03T12:00:00.000Z", "metadata": { "name": "my-pin" } }] } diff --git a/adapters/pinata-style/adapter.yaml b/adapters/pinata-style/adapter.yaml index 0c4f5a0e..2cfc3523 100644 --- a/adapters/pinata-style/adapter.yaml +++ b/adapters/pinata-style/adapter.yaml @@ -26,9 +26,12 @@ endpoints: - route: /pinning/pinJSONToIPFS method: POST handler: scripts/pinning.star#on_pin_json + # concurrency_key serialises the read-modify-write (find the pin row, + # then delete it) against concurrent unpinners of the same CID. - route: /pinning/unpin/{cid} method: DELETE handler: scripts/pinning.star#on_unpin + concurrency_key: cid # --- Data --- - route: /data/pinList diff --git a/adapters/pinata-style/scripts/data.star b/adapters/pinata-style/scripts/data.star index 5f95655e..1d71a640 100644 --- a/adapters/pinata-style/scripts/data.star +++ b/adapters/pinata-style/scripts/data.star @@ -36,22 +36,23 @@ def on_pin_list(req): "rows": rows, }) -# on_pin_by_hash looks up a pin by its hash (cid query parameter). +# on_pin_by_hash looks up a pin by its hash (hash query parameter — +# required, as in the real API). def on_pin_by_hash(req): err = _require_auth(req) if err != None: return err cid = req["query"].get("hash", "") - if cid == None: - cid = "" + if cid == None or cid == "": + return _p_err(400, "BAD_REQUEST", "hash query parameter is required") c = store_collection("pins") docs = c.list() rows = [] for doc in docs: - if cid == "" or doc.get("ipfs_pin_hash", "") == cid: + if doc.get("ipfs_pin_hash", "") == cid: rows.append(_pin_row(doc)) return respond(200, { @@ -106,10 +107,11 @@ def _apply_pin_list_query(req, rows): rows = query_select(rows, f if len(f) > 0 else None, None, "", None, None, None) count = len(rows) + # Real API pages at 10 rows by default; count stays the filtered total. page_limit = _to_int(_get_query(req, "pageLimit")) - page_offset = _to_int(_get_query(req, "pageOffset")) - if page_limit > 0 or page_offset > 0: - rows = query_select(rows, None, "", "", page_limit if page_limit > 0 else None, page_offset, None) + if page_limit <= 0: + page_limit = 10 + rows = query_select(rows, None, "", "", page_limit, _to_int(_get_query(req, "pageOffset")), None) return count, rows # _meta_name extracts the "name" value from a metadata query-param string diff --git a/adapters/pinata-style/scripts/lib.star b/adapters/pinata-style/scripts/lib.star index 5daf9f03..f4d6a228 100644 --- a/adapters/pinata-style/scripts/lib.star +++ b/adapters/pinata-style/scripts/lib.star @@ -53,27 +53,50 @@ def _p_err(status, reason, details): }, }) -# _cid_gen generates a deterministic-looking CIDv0 string (Qm + 44 base58 chars). -# Uses a monotonic counter so each pin gets a unique CID. -def _cid_gen(): - n = store_kv_incr("pinata", "cid_seq") - # Base58 alphabet (Bitcoin / IPFS flavour) +# _hex_val maps one lowercase hex digit to its value (find == -1 never +# happens: crypto.sha256's default encoding is lowercase hex). +def _hex_val(ch): + return "0123456789abcdef".find(ch) + +# _base58 encodes a non-negative int in the Bitcoin/IPFS alphabet. +def _base58(n): alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - seed = n + 1 - suffix = "" - for _ in range(44): - suffix = alphabet[seed % 58] + suffix - seed = seed // 58 + 1 - return "Qm" + suffix + out = "" + while n > 0: + out = alphabet[n % 58] + out + n = n // 58 + return out + +# _cid_for returns a real CIDv0 for content bytes: base58 of the sha2-256 +# multihash (0x12 0x20 || digest), which is why it always starts "Qm". +# Content addressing means identical bytes pin to the identical CID, so +# re-pins are detectable (isDuplicate), like the real API. +def _cid_for(data): + n = 0x1220 # multihash prefix: sha2-256, 32-byte digest + digest = crypto.sha256(data) + for i in range(len(digest)): + n = n * 16 + _hex_val(digest[i]) + return _base58(n) # _pin_id generates a Pinata pin row id. def _pin_id(): n = store_kv_incr("pinata", "pin_seq") return str(7000000000 + n) -# _timestamp generates a synthetic ISO-8601 timestamp. +# _timestamp returns the pin time in Pinata's ISO-8601 millisecond form +# (real API stamps each pin at request time; now_rfc3339 stops at seconds). def _timestamp(): - return "2024-06-15T12:30:00.000Z" + s = clock.now_rfc3339() + if s.endswith("Z"): + return s[:-1] + ".000Z" + return s + +# _pin_for returns the stored pin doc with the given CID, or None. +def _pin_for(cid): + for doc in store_collection("pins").list(): + if doc.get("ipfs_pin_hash", "") == cid: + return doc + return None # _pin_public returns the Pinata-shaped pin list row. def _pin_row(doc): @@ -111,11 +134,12 @@ def _to_int(s): return 0 return n -# _pin_result returns the Pinata-shaped pin result (from pinFileToIPFS / pinJSONToIPFS). -def _pin_result(doc): +# _pin_result returns the Pinata-shaped pin result (from pinFileToIPFS / +# pinJSONToIPFS). is_duplicate is True only on the re-pin response. +def _pin_result(doc, is_duplicate): return { "IpfsHash": doc.get("ipfs_pin_hash", ""), "PinSize": doc.get("size", 0), "Timestamp": doc.get("timestamp", ""), - "isDuplicate": doc.get("is_duplicate", False), + "isDuplicate": is_duplicate, } diff --git a/adapters/pinata-style/scripts/pinning.star b/adapters/pinata-style/scripts/pinning.star index 9c86a3fd..cc0f5403 100644 --- a/adapters/pinata-style/scripts/pinning.star +++ b/adapters/pinata-style/scripts/pinning.star @@ -6,53 +6,62 @@ # POST /pinning/pinJSONToIPFS ({pinataContent}) → { IpfsHash, PinSize, Timestamp } # DELETE /pinning/unpin/{cid} → 200 OK -# on_pin_file handles multipart file upload pinning. +# on_pin_file handles multipart file upload pinning. The file part's bytes +# become the pin's size and CID; a pinataMetadata form field names it. def on_pin_file(req): err = _require_auth(req) if err != None: return err - cid = _cid_gen() - row_id = _pin_id() - ts = _timestamp() - - # Determine a synthetic pin size. For multipart uploads the body is not - # parsed as JSON (body will be nil); we use a deterministic default. - pin_size = 1024 - - # Extract a name from pinataMetadata if present in the body. name = "pin-file" - body = req.get("body") - if body != None: - meta = body.get("pinataMetadata") - if meta != None: - mn = meta.get("name") - if mn != None: - name = mn + data = None + raw = req.get("raw_body") + if raw == None: + raw = "" + parts, perr = parse_multipart(_header(req, "Content-Type"), raw) + if perr == None: + for p in parts: + if p["filename"] != None: + data = p["data"] + elif p["name"] == "pinataMetadata": + meta = json_safe_decode(p["data"]) + if meta != None and meta.get("name") != None: + name = meta.get("name") + + # Real API rejects an upload with no file part. + if data == None: + return _p_err(400, "BAD_REQUEST", "No file was detected in the request") + + cid = _cid_for(data) + + # Identical bytes are already pinned: report the duplicate, no new row. + existing = _pin_for(cid) + if existing != None: + return respond(200, _pin_result(existing, True)) + ts = _timestamp() doc = { - "id": row_id, + "id": _pin_id(), "ipfs_pin_hash": cid, - "size": pin_size, + "size": len(data), "date_pinned": ts, "timestamp": ts, "metadata": {"name": name}, "is_duplicate": False, } + store_collection("pins").insert(doc) + return respond(200, _pin_result(doc, False)) - c = store_collection("pins") - c.insert(doc) - - return respond(200, _pin_result(doc)) - -# on_pin_json handles JSON pinning. +# on_pin_json handles JSON pinning. PinSize/CID derive from the serialized +# pinataContent (compact, key-sorted json.encode — deterministic). def on_pin_json(req): err = _require_auth(req) if err != None: return err + # The engine hands a missing JSON body over as an empty dict, not None. body = req.get("body") - if body == None: + if body == None or len(body) == 0: return _p_err(400, "BAD_REQUEST", "Request body is required") # Pinata wraps content in pinataContent; fall back to body itself. @@ -60,9 +69,6 @@ def on_pin_json(req): if content == None: content = body - # Synthetic pin size based on content. - pin_size = 512 - # Extract name from pinataMetadata if present. name = "pin-json" meta = body.get("pinataMetadata") @@ -71,24 +77,26 @@ def on_pin_json(req): if mn != None: name = mn - cid = _cid_gen() - row_id = _pin_id() - ts = _timestamp() + data = json.encode(content) + cid = _cid_for(data) + + # Identical content is already pinned: report the duplicate, no new row. + existing = _pin_for(cid) + if existing != None: + return respond(200, _pin_result(existing, True)) + ts = _timestamp() doc = { - "id": row_id, + "id": _pin_id(), "ipfs_pin_hash": cid, - "size": pin_size, + "size": len(data), "date_pinned": ts, "timestamp": ts, "metadata": {"name": name}, "is_duplicate": False, } - - c = store_collection("pins") - c.insert(doc) - - return respond(200, _pin_result(doc)) + store_collection("pins").insert(doc) + return respond(200, _pin_result(doc, False)) # on_unpin removes a pin by CID. def on_unpin(req): @@ -100,12 +108,10 @@ def on_unpin(req): if cid == None or cid == "": return _p_err(400, "BAD_REQUEST", "CID path parameter is required") - c = store_collection("pins") - docs = c.list() - for doc in docs: - if doc.get("ipfs_pin_hash", "") == cid: - c.delete(doc.get("id", "")) - return respond(200, {}) + doc = _pin_for(cid) + if doc != None: + store_collection("pins").delete(doc.get("id", "")) + return respond(200, {}) # Pinata returns 403 when unpinning a CID that isn't pinned. return _p_err(403, "FORBIDDEN", "CID not pinned to this account") diff --git a/adapters/pinata_style_test.go b/adapters/pinata_style_test.go new file mode 100644 index 00000000..660acfa7 --- /dev/null +++ b/adapters/pinata_style_test.go @@ -0,0 +1,334 @@ +package adapters + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the pinata-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: the API-key-pair / Bearer-JWT +// gate, content-addressed CIDv0 pinning (JSON + multipart file) with +// isDuplicate re-pin detection, the pinList filters and default-10 paging, +// pinByHash, unpin, and the {error:{reason,details}} envelope. +var ( + pinBearer = map[string]string{"Authorization": "Bearer pinata-jwt"} + pinKeyPair = map[string]string{"pinata_api_key": "vm-key", "pinata_secret_api_key": "vm-secret"} +) + +// Expected CIDs: base58(0x12 0x20 || sha256(content)) — real CIDv0 math, +// so identical bytes always pin to the identical "Qm…" hash. +const ( + pinJSONHash = "QmYGx7Wzqe5prvEsTSzYBQN8xViYUM9qsWJSF5EENLcNmM" // {"hello":"world"} +) + +type pinataFixture struct { + t *testing.T + vc *clock.Clock + vmPin *starlark.VM + vmData *starlark.VM +} + +func newPinataFixture(t *testing.T, start time.Time) *pinataFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "pinata-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &pinataFixture{t: t, vc: vc, vmPin: load("pinning.star"), vmData: load("data.star")} +} + +// call invokes handler on vm with explicit headers (nil = header absent). +func (f *pinataFixture) call(vm *starlark.VM, handler, method, path string, params, query map[string]string, body map[string]any, headers map[string]string) starlark.Response { + f.t.Helper() + resp, err := vm.Call(handler, starlark.Request{ + Method: method, Path: path, Host: "api.pinata.cloud", + Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// callMultipart is call with a raw multipart body, the way the engine +// delivers file uploads (JSON body nil, bytes in raw_body). +func (f *pinataFixture) callMultipart(handler, path, contentType, rawBody string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + for k, v := range pinBearer { + headers[k] = v + } + headers["Content-Type"] = contentType + resp, err := f.vmPin.Call(handler, starlark.Request{ + Method: "POST", Path: path, Host: "api.pinata.cloud", Headers: headers, RawBody: rawBody, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// pinRows pulls the rows array out of a {count, rows} envelope. +func pinRows(t *testing.T, r starlark.Response) []any { + t.Helper() + rows, ok := r.Body["rows"].([]any) + if !ok { + t.Fatalf("rows = %v, want an array (status %d)", r.Body["rows"], r.Status) + } + return rows +} + +// pinErrReason extracts error.reason from the Pinata error envelope. +func pinErrReason(r starlark.Response) string { + e, _ := r.Body["error"].(map[string]any) + reason, _ := e["reason"].(string) + return reason +} + +// pinataMultipart builds a file + pinataMetadata multipart body. +func pinataMultipart(boundary, filename, fileData, metaJSON string) string { + var b strings.Builder + fmt.Fprintf(&b, "--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=%q\r\nContent-Type: application/octet-stream\r\n\r\n%s\r\n", + boundary, filename, fileData) + fmt.Fprintf(&b, "--%s\r\nContent-Disposition: form-data; name=\"pinataMetadata\"\r\nContent-Type: application/json\r\n\r\n%s\r\n", boundary, metaJSON) + fmt.Fprintf(&b, "--%s--\r\n", boundary) + return b.String() +} + +func TestPinataPinningLifecycle(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newPinataFixture(t, base) + + // ===== missing or half-present credentials are 401 with the error envelope ===== + // No credentials, key without secret, and an empty Bearer all 401. + for name, headers := range map[string]map[string]string{ + "no credentials": nil, + "key without secret": {"pinata_api_key": "vm-key"}, + "bearer no token": {"Authorization": "Bearer "}, + } { + r := f.call(f.vmData, "on_test_auth", "GET", "/data/testAuthentication", nil, nil, nil, headers) + if r.Status != 401 || pinErrReason(r) != "UNAUTHORIZED" { + t.Fatalf("%s -> %d %v, want 401 UNAUTHORIZED", name, r.Status, r.Body) + } + } + + // ===== the API key pair and a Bearer JWT both open testAuthentication ===== + // Either scheme yields the congratulations message. + for name, headers := range map[string]map[string]string{"key pair": pinKeyPair, "bearer": pinBearer} { + r := f.call(f.vmData, "on_test_auth", "GET", "/data/testAuthentication", nil, nil, nil, headers) + if r.Status != 200 || r.Body["message"] != "Congratulations! You are communicating with the Pinata API!" { + t.Fatalf("%s testAuthentication -> %d %v", name, r.Status, r.Body) + } + } + + // ===== pinJSONToIPFS pins content to a real CIDv0 ===== + // Size is the serialized content's byte length; the CID is real CIDv0 + // (base58 of the sha2-256 multihash), stamped at request time. + jsonPin := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{ + "pinataContent": map[string]any{"hello": "world"}, + "pinataMetadata": map[string]any{"name": "vm-pin"}, + }, pinBearer) + if jsonPin.Status != 200 { + t.Fatalf("pinJSONToIPFS -> %d: %v", jsonPin.Status, jsonPin.Body) + } + if jsonPin.Body["IpfsHash"] != pinJSONHash { + t.Fatalf("IpfsHash = %v, want the content's CIDv0 %s", jsonPin.Body["IpfsHash"], pinJSONHash) + } + if jsonPin.Body["PinSize"] != int64(17) { // len(`{"hello":"world"}`) + t.Fatalf("PinSize = %v, want 17 (serialized bytes)", jsonPin.Body["PinSize"]) + } + if jsonPin.Body["Timestamp"] != "2026-02-03T12:00:00.000Z" || jsonPin.Body["isDuplicate"] != false { + t.Fatalf("pin result = %v, want request-time Timestamp and isDuplicate false", jsonPin.Body) + } + // A body-less pin is a 400. + if r := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, nil, pinBearer); r.Status != 400 || pinErrReason(r) != "BAD_REQUEST" { + t.Fatalf("body-less pinJSONToIPFS -> %d %v, want 400 BAD_REQUEST", r.Status, r.Body) + } + + // ===== re-pinning identical content is isDuplicate, not a new pin ===== + // Same bytes -> same CID, isDuplicate true, and no second row. + dup := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{ + "pinataContent": map[string]any{"hello": "world"}, + }, pinKeyPair) + if dup.Status != 200 || dup.Body["IpfsHash"] != pinJSONHash || dup.Body["isDuplicate"] != true { + t.Fatalf("re-pin -> %d %v, want same CID and isDuplicate true", dup.Status, dup.Body) + } + other := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{ + "pinataContent": map[string]any{"hello": "stunt"}, + }, pinBearer) + if other.Status != 200 || other.Body["IpfsHash"] == pinJSONHash || other.Body["isDuplicate"] != false { + t.Fatalf("different content -> %d %v, want a fresh CID", other.Status, other.Body) + } + + // ===== pinFileToIPFS sizes and names the pin from the multipart parts ===== + // The file part's bytes set PinSize/CID; pinataMetadata names it. A + // multipart body with no file part is a 400. + f.vc.Advance(30 * time.Minute) + mp := pinataMultipart("vmBoundary", "hello.bin", strings.Repeat("ipfs", 500), `{"name":"vm-file-pin"}`) + filePin := f.callMultipart("on_pin_file", "/pinning/pinFileToIPFS", + "multipart/form-data; boundary=vmBoundary", mp) + if filePin.Status != 200 || filePin.Body["PinSize"] != int64(2000) || filePin.Body["isDuplicate"] != false { + t.Fatalf("pinFileToIPFS -> %d %v, want PinSize 2000 from the file part", filePin.Status, filePin.Body) + } + fileHash, _ := filePin.Body["IpfsHash"].(string) + if !strings.HasPrefix(fileHash, "Qm") || len(fileHash) != 46 { + t.Fatalf("file IpfsHash = %q, want a 46-char CIDv0", fileHash) + } + if filePin.Body["Timestamp"] != "2026-02-03T12:30:00.000Z" { + t.Fatalf("file Timestamp = %v, want the advanced clock time", filePin.Body["Timestamp"]) + } + metaOnly := "--vmB\r\nContent-Disposition: form-data; name=\"pinataMetadata\"\r\n\r\n{}\r\n--vmB--\r\n" + if r := f.callMultipart("on_pin_file", "/pinning/pinFileToIPFS", "multipart/form-data; boundary=vmB", metaOnly); r.Status != 400 || pinErrReason(r) != "BAD_REQUEST" { + t.Fatalf("file-less pinFileToIPFS -> %d %v, want 400 BAD_REQUEST", r.Status, r.Body) + } + + // ===== pinList filters by hash, size, status, and metadata name ===== + // Three rows so far: two JSON pins (17/18 bytes, 12:00) and the file + // (2000 bytes, 12:30). Every filter narrows within them. + list := func(query map[string]string) starlark.Response { + return f.call(f.vmData, "on_pin_list", "GET", "/data/pinList", nil, query, nil, pinBearer) + } + if r := list(map[string]string{"hashContains": pinJSONHash}); r.Status != 200 || r.Body["count"] != int64(1) { + t.Fatalf("hashContains -> %d %v, want the one matching row", r.Status, r.Body) + } + if r := list(map[string]string{"pinSizeMin": "1000"}); r.Body["count"] != int64(1) { + t.Fatalf("pinSizeMin 1000 -> %v, want only the 2000-byte file", r.Body["count"]) + } + if r := list(map[string]string{"pinSizeMax": "1000"}); r.Body["count"] != int64(2) { + t.Fatalf("pinSizeMax 1000 -> %v, want the two JSON pins", r.Body["count"]) + } + if r := list(map[string]string{"status": "unpinned"}); r.Status != 200 || r.Body["count"] != int64(0) || len(pinRows(t, r)) != 0 { + t.Fatalf("status unpinned -> %d %v, want an empty result (no unpin history)", r.Status, r.Body) + } + if r := list(map[string]string{"status": "all"}); r.Body["count"] != int64(3) { + t.Fatalf("status all -> %v, want all three pins", r.Body["count"]) + } + meta := list(map[string]string{"metadata": `{"name":"vm-file-pin"}`}) + if meta.Status != 200 || meta.Body["count"] != int64(1) { + t.Fatalf("metadata name filter -> %d %v, want the file pin", meta.Status, meta.Body) + } + if mrows := pinRows(t, meta); len(mrows) == 1 { + row, _ := mrows[0].(map[string]any) + md, _ := row["metadata"].(map[string]any) + if md["name"] != "vm-file-pin" || row["ipfs_pin_hash"] != fileHash { + t.Fatalf("metadata-filtered row = %v, want the named file pin", mrows[0]) + } + } + + // ===== pinStart/pinEnd bound the date-pinned window ===== + // ISO-8601 bounds: before 12:15 keeps the JSON pins, after 12:15 the file. + if r := list(map[string]string{"pinEnd": "2026-02-03T12:15:00.000Z"}); r.Body["count"] != int64(2) { + t.Fatalf("pinEnd 12:15 -> %v, want the two 12:00 JSON pins", r.Body["count"]) + } + if r := list(map[string]string{"pinStart": "2026-02-03T12:15:00.000Z"}); r.Body["count"] != int64(1) { + t.Fatalf("pinStart 12:15 -> %v, want only the 12:30 file pin", r.Body["count"]) + } + if r := list(map[string]string{"pinStart": "2026-06-01T00:00:00.000Z", "pinEnd": "2026-06-02T00:00:00.000Z"}); r.Body["count"] != int64(0) { + t.Fatalf("empty date window -> %v, want 0", r.Body["count"]) + } + + // ===== pinByHash requires hash and matches the CID exactly ===== + byHash := func(query map[string]string) starlark.Response { + return f.call(f.vmData, "on_pin_by_hash", "GET", "/data/pinByHash", nil, query, nil, pinBearer) + } + if r := byHash(map[string]string{"hash": fileHash}); r.Status != 200 || r.Body["count"] != int64(1) || len(pinRows(t, r)) != 1 { + t.Fatalf("pinByHash file -> %d %v, want exactly the file pin", r.Status, r.Body) + } + if r := byHash(map[string]string{"hash": "QmNotAPinnedCid0000000000000000000000000000"}); r.Status != 200 || r.Body["count"] != int64(0) { + t.Fatalf("pinByHash unknown -> %d %v, want an empty 200", r.Status, r.Body) + } + if r := byHash(nil); r.Status != 400 || pinErrReason(r) != "BAD_REQUEST" { + t.Fatalf("pinByHash without hash -> %d %v, want 400 BAD_REQUEST", r.Status, r.Body) + } + + // ===== pinList pages at the real default of 10 rows ===== + // 12 more JSON pins -> 15 rows; default page 10, count stays 15. + f.vc.Advance(time.Hour) + for i := 1; i <= 12; i++ { + r := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{ + "pinataContent": map[string]any{"i": i}, + }, pinBearer) + if r.Status != 200 || r.Body["isDuplicate"] != false { + t.Fatalf("pin %d -> %d %v, want a fresh pin", i, r.Status, r.Body) + } + } + page1 := list(nil) + if rows := pinRows(t, page1); len(rows) != 10 || page1.Body["count"] != int64(15) { + t.Fatalf("default pinList -> %d rows count %v, want 10 of 15", len(rows), page1.Body["count"]) + } + page2 := list(map[string]string{"pageOffset": "10"}) + if rows := pinRows(t, page2); len(rows) != 5 || page2.Body["count"] != int64(15) { + t.Fatalf("pageOffset 10 -> %d rows count %v, want the remaining 5 of 15", len(rows), page2.Body["count"]) + } + tail := list(map[string]string{"pageLimit": "3", "pageOffset": "12"}) + if rows := pinRows(t, tail); len(rows) != 3 || tail.Body["count"] != int64(15) { + t.Fatalf("pageLimit 3 offset 12 -> %d rows, want 3", len(rows)) + } + id1, _ := pinRows(t, page1)[0].(map[string]any)["id"].(string) + id2, _ := pinRows(t, page2)[0].(map[string]any)["id"].(string) + if id1 == id2 { + t.Fatalf("paging repeated row %s", id1) + } + + // ===== unpin removes the CID; a second unpin is 403 FORBIDDEN ===== + // Unpin 200s once, empties pinByHash, drops the list count, then 403s. + unpin := f.call(f.vmPin, "on_unpin", "DELETE", "/pinning/unpin/"+fileHash, + map[string]string{"cid": fileHash}, nil, nil, pinBearer) + if unpin.Status != 200 { + t.Fatalf("unpin -> %d: %v", unpin.Status, unpin.Body) + } + if r := byHash(map[string]string{"hash": fileHash}); r.Status != 200 || r.Body["count"] != int64(0) { + t.Fatalf("pinByHash after unpin -> %d %v, want 0 rows", r.Status, r.Body) + } + if r := list(map[string]string{"hashContains": fileHash}); r.Body["count"] != int64(0) { + t.Fatalf("pinList after unpin -> %v, want the file pin gone", r.Body["count"]) + } + if r := list(nil); r.Body["count"] != int64(14) { + t.Fatalf("pinList count after unpin = %v, want 14", r.Body["count"]) + } + if r := f.call(f.vmPin, "on_unpin", "DELETE", "/pinning/unpin/"+fileHash, + map[string]string{"cid": fileHash}, nil, nil, pinBearer); r.Status != 403 || pinErrReason(r) != "FORBIDDEN" { + t.Fatalf("second unpin -> %d %v, want 403 FORBIDDEN", r.Status, r.Body) + } + if r := f.call(f.vmPin, "on_unpin", "DELETE", "/pinning/unpin/QmNotAPinnedCid0000000000000000000000000000", + map[string]string{"cid": "QmNotAPinnedCid0000000000000000000000000000"}, nil, nil, pinKeyPair); r.Status != 403 { + t.Fatalf("unpin unknown CID -> %d, want 403", r.Status) + } +} diff --git a/adapters/reddit_style_test.go b/adapters/reddit_style_test.go new file mode 100644 index 00000000..7da4c5c0 --- /dev/null +++ b/adapters/reddit_style_test.go @@ -0,0 +1,308 @@ +package adapters + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the reddit-style adapter scripts directly (lib.star preloaded) over +// a shared store and virtual clock: the User-Agent gate on both routes, the +// HTTP Basic client-credential gate on the token endpoint, the +// authorization_code (permanent -> access + refresh) and refresh_token +// (access only) grants, the Bearer gate on submit with the USER_REQUIRED +// json envelope, the t3_ thing shape, and the one-hour token expiry. +const redditUA = "stunt-vm-suite/1.0 (by /u/tester)" + +// redditBasic computes the HTTP Basic client-credential header value. +func redditBasic() string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte("vm-client-id:vm-client-secret")) +} + +type redditFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newRedditFixture(t *testing.T, start time.Time) *redditFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "reddit-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &redditFixture{t: t, vc: vc, host: "www.reddit.test", vms: map[string]*starlark.VM{ + "oauth": load("oauth.star"), "submit": load("submit.star"), + }} +} + +// call drives one POST handler; headers always carry the UA unless ua is +// overridden ("" means none, mirroring a request without the header). +func (f *redditFixture) call(group, handler, path string, form map[string]any, auth, ua string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if ua != "" { + headers["User-Agent"] = ua + } + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: "POST", Path: path, Host: f.host, Headers: headers, Body: form, + Params: map[string]string{}, Query: map[string]string{}, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// rdErrorTriple unwraps the Reddit json envelope's first error triple +// ([CODE, message, field]). +func rdErrorTriple(t *testing.T, r starlark.Response) []any { + t.Helper() + j, ok := r.Body["json"].(map[string]any) + if !ok { + t.Fatalf("body has no json envelope: %v", r.Body) + } + errs, ok := j["errors"].([]any) + if !ok || len(errs) == 0 { + t.Fatalf("json.errors = %v, want a non-empty list", j["errors"]) + } + triple, ok := errs[0].([]any) + if !ok || len(triple) < 2 { + t.Fatalf("error triple = %v, want [CODE, message, field]", errs[0]) + } + return triple +} + +// TestRedditTokenEndpoint: the User-Agent and HTTP Basic gates plus the +// authorization_code and refresh_token grant shapes. +func TestRedditTokenEndpoint(t *testing.T) { + f := newRedditFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC)) + tokenPath := "/api/v1/access_token" + + // ===== a missing or generic User-Agent is 429 on both routes ===== + // Reddit 429s absent/generic UAs; the mock reproduces it. + noUA := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "authorization_code"}, redditBasic(), "") + if noUA.Status != 429 || noUA.Body["message"] != "Too Many Requests" { + t.Fatalf("no UA -> %d %v, want 429 Too Many Requests", noUA.Status, noUA.Body) + } + if rdNum(noUA.Body["error"]) != 429 { + t.Fatalf("UA-reject error = %v (%T), want 429", noUA.Body["error"], noUA.Body["error"]) + } + seeded := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "authorization_code", "duration": "permanent"}, redditBasic(), redditUA) + access, _ := seeded.Body["access_token"].(string) + generic := f.call("submit", "on_submit", "/api/submit", + map[string]any{"sr": "test", "title": "Title"}, "Bearer "+access, "python-requests/2.31.0") + if generic.Status != 429 { + t.Fatalf("generic UA (no parens) -> %d, want 429", generic.Status) + } + + // ===== the token endpoint requires HTTP Basic client credentials ===== + // No Basic header -> 401 invalid_client; an unknown grant is a 400. + if r := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "authorization_code"}, "", redditUA); r.Status != 401 || r.Body["error"] != "invalid_client" { + t.Fatalf("no Basic -> %d %v, want 401 invalid_client", r.Status, r.Body) + } + if r := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "client_credentials"}, redditBasic(), redditUA); r.Status != 400 || r.Body["error"] != "unsupported_grant_type" { + t.Fatalf("unknown grant -> %d %v, want 400 unsupported_grant_type", r.Status, r.Body) + } + + // ===== a permanent authorization_code mints access and refresh together ===== + if seeded.Status != 200 { + t.Fatalf("authorization_code -> %d: %v", seeded.Status, seeded.Body) + } + if access == "" { + t.Fatalf("authorization_code returned no access_token: %v", seeded.Body) + } + refresh, _ := seeded.Body["refresh_token"].(string) + if refresh == "" || refresh[:6] != "rdref_" { + t.Fatalf("permanent grant refresh_token = %q, want an rdref_ token", refresh) + } + rdAssertTokenShape(t, seeded.Body) + + // A temporary grant (no duration) issues no refresh token at all. + temp := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "authorization_code"}, redditBasic(), redditUA) + if _, exists := temp.Body["refresh_token"]; exists { + t.Fatalf("temporary grant issued a refresh_token: %v", temp.Body) + } + rdAssertTokenShape(t, temp.Body) + + // ===== a refresh grant returns a fresh access token and no new refresh ===== + // Reddit only issues a refresh token on the initial permanent grant. + refreshed := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "refresh_token", "refresh_token": refresh}, redditBasic(), redditUA) + if refreshed.Status != 200 { + t.Fatalf("refresh grant -> %d: %v", refreshed.Status, refreshed.Body) + } + if _, exists := refreshed.Body["refresh_token"]; exists { + t.Fatalf("refresh grant returned a new refresh_token: %v", refreshed.Body) + } + if got, _ := refreshed.Body["access_token"].(string); got == access { + t.Fatalf("refresh grant returned the same access token %q, want a fresh one", got) + } + rdAssertTokenShape(t, refreshed.Body) + if r := f.call("oauth", "on_access_token", tokenPath, + map[string]any{"grant_type": "refresh_token", "refresh_token": "rdref_unknown"}, redditBasic(), redditUA); r.Status != 400 || r.Body["error"] != "invalid_grant" { + t.Fatalf("unknown refresh token -> %d %v, want 400 invalid_grant", r.Status, r.Body) + } +} + +// rdAssertTokenShape checks the shared OAuth token response fields. +func rdAssertTokenShape(t *testing.T, body map[string]any) { + t.Helper() + if body["token_type"] != "bearer" { + t.Fatalf("token_type = %v, want bearer", body["token_type"]) + } + if rdNum(body["expires_in"]) != 3600 { + t.Fatalf("expires_in = %v (%T), want 3600", body["expires_in"], body["expires_in"]) + } + if body["scope"] != "submit identity" { + t.Fatalf("scope = %v, want 'submit identity'", body["scope"]) + } +} + +// TestRedditSubmit: the Bearer gate, the t3_ thing envelope, and the +// HTTP-200 error-triple vocabulary. +func TestRedditSubmit(t *testing.T) { + f := newRedditFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC)) + mint := func() string { + t.Helper() + r := f.call("oauth", "on_access_token", "/api/v1/access_token", + map[string]any{"grant_type": "authorization_code"}, redditBasic(), redditUA) + tok, _ := r.Body["access_token"].(string) + if r.Status != 200 || tok == "" { + t.Fatalf("mint access token -> %d: %v", r.Status, r.Body) + } + return tok + } + access := mint() + + // ===== submit requires a bearer the adapter itself minted ===== + // Missing, unknown, and expired tokens all get Reddit's USER_REQUIRED + // json envelope. + noAuth := f.call("submit", "on_submit", "/api/submit", + map[string]any{"sr": "test", "title": "Title"}, "", redditUA) + if noAuth.Status != 401 { + t.Fatalf("submit without bearer -> %d, want 401", noAuth.Status) + } + if code := rdErrorTriple(t, noAuth)[0]; code != "USER_REQUIRED" { + t.Fatalf("no-bearer error code = %v, want USER_REQUIRED", code) + } + unknown := f.call("submit", "on_submit", "/api/submit", + map[string]any{"sr": "test", "title": "Title"}, "Bearer rdtok_999", redditUA) + if unknown.Status != 401 { + t.Fatalf("submit with unknown bearer -> %d, want 401", unknown.Status) + } + if code := rdErrorTriple(t, unknown)[0]; code != "USER_REQUIRED" { + t.Fatalf("unknown-bearer error code = %v, want USER_REQUIRED", code) + } + + // ===== a valid submit returns the t3_ thing envelope ===== + ok := f.call("submit", "on_submit", "/api/submit", map[string]any{ + "sr": "test", "title": "Hello from the stunt VM suite", "kind": "self", + }, "Bearer "+access, redditUA) + if ok.Status != 200 { + t.Fatalf("submit -> %d: %v", ok.Status, ok.Body) + } + j, _ := ok.Body["json"].(map[string]any) + if errs, _ := j["errors"].([]any); len(errs) != 0 { + t.Fatalf("valid submit errors = %v, want empty", errs) + } + data, _ := j["data"].(map[string]any) + postID, _ := data["id"].(string) + if postID == "" { + t.Fatalf("submit data = %v, want an id", data) + } + if data["name"] != "t3_"+postID { + t.Fatalf("submit name = %v, want t3_%s", data["name"], postID) + } + url, _ := data["url"].(string) + if want := "/r/test/comments/" + postID + "/hello_from_the_stunt_vm_suite/"; !strings.Contains(url, want) { + t.Fatalf("submit url = %q, want it to contain %q", url, want) + } + + // ===== missing sr or title stay HTTP 200 with Reddit error triples ===== + noSr := f.call("submit", "on_submit", "/api/submit", + map[string]any{"title": "Title"}, "Bearer "+access, redditUA) + if noSr.Status != 200 { + t.Fatalf("submit without sr -> %d, want 200 (Reddit reports field errors in-band)", noSr.Status) + } + if triple := rdErrorTriple(t, noSr); triple[0] != "SUBREDDIT_REQUIRED" || triple[2] != "sr" { + t.Fatalf("no-sr triple = %v, want [SUBREDDIT_REQUIRED, ..., sr]", triple) + } + noTitle := f.call("submit", "on_submit", "/api/submit", + map[string]any{"sr": "test"}, "Bearer "+access, redditUA) + if noTitle.Status != 200 { + t.Fatalf("submit without title -> %d, want 200", noTitle.Status) + } + if triple := rdErrorTriple(t, noTitle); triple[0] != "NO_TEXT" || triple[2] != "title" { + t.Fatalf("no-title triple = %v, want [NO_TEXT, ..., title]", triple) + } + + // ===== an access token dies after its one-hour window ===== + // The minted expires_at is honored on read (virtual clock, no sleeps). + f.vc.Advance(3601 * time.Second) + expired := f.call("submit", "on_submit", "/api/submit", + map[string]any{"sr": "test", "title": "Title"}, "Bearer "+access, redditUA) + if expired.Status != 401 { + t.Fatalf("submit with expired bearer -> %d, want 401", expired.Status) + } + if code := rdErrorTriple(t, expired)[0]; code != "USER_REQUIRED" { + t.Fatalf("expired-bearer error code = %v, want USER_REQUIRED", code) + } +} + +// rdNum reads a response number whether the adapter produced a fresh +// Starlark int or a value that round-tripped the JSON document store. +func rdNum(v any) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + } + return -1 +} diff --git a/adapters/revenuecat-style/README.md b/adapters/revenuecat-style/README.md index d4972949..3b18aa7c 100644 --- a/adapters/revenuecat-style/README.md +++ b/adapters/revenuecat-style/README.md @@ -147,6 +147,8 @@ with the real RC field names: ```json { + "request_date": "2026-08-14T10:00:00Z", + "request_date_ms": 1786768800000, "subscriber": { "original_app_user_id": "user-1", "first_seen": "2026-08-14T10:00:00Z", @@ -195,10 +197,21 @@ deterministically. ## Auth -Bearer authentication: `Authorization: Bearer ` against the token store. -The well-known static test key **`sk_test_revenuecat_style_mock_key`** is -seeded once on first request (insert-once); any other key is rejected with -`401 {"code": 401, "message": "Invalid API key."}`. +Bearer authentication: `Authorization: Bearer ` against the token store, +mirroring RevenueCat's key split: + +- **Secret key** **`sk_test_revenuecat_style_mock_key`** — accepted on every + endpoint (like real `sk_` project secret keys). +- **Public SDK key** **`pk_test_revenuecat_style_mock_key`** — accepted only on + the SDK-facing endpoints (`GET /v1/subscribers/{id}`, `POST /v1/receipts`). + On restricted endpoints (subscriber `POST`/`DELETE`, revoke, webhook + management) it is rejected with + `401 {"code": 401, "message": "This endpoint requires a secret API key."}`. + +Both keys are seeded once on first request (insert-once); any other key is +rejected with `401 {"code": 401, "message": "Invalid API key."}`, and a +missing header with +`401 {"code": 401, "message": "Missing API key in Authorization header."}`. ## Usage diff --git a/adapters/revenuecat-style/adapter.yaml b/adapters/revenuecat-style/adapter.yaml index 01e2faa1..4c615d29 100644 --- a/adapters/revenuecat-style/adapter.yaml +++ b/adapters/revenuecat-style/adapter.yaml @@ -59,8 +59,10 @@ resources: - name: webhooks kind: collection -# Auth scheme metadata (mock: Bearer sk_... public SDK key; the well-known -# static test key in scripts/lib.star is seeded into the token store). +# Auth scheme metadata (mock: Bearer keys; the well-known static test secret +# sk_... key and public pk_... SDK key in scripts/lib.star are seeded into +# the token store — secrets work everywhere, publics only on SDK-facing +# routes, like the real API). identity: token_scheme: bearer diff --git a/adapters/revenuecat-style/scripts/lib.star b/adapters/revenuecat-style/scripts/lib.star index 1a602a0f..65a54adb 100644 --- a/adapters/revenuecat-style/scripts/lib.star +++ b/adapters/revenuecat-style/scripts/lib.star @@ -8,22 +8,26 @@ # One day in seconds, assembled at runtime (no long digit runs in source). _DAY_SECONDS = 24 * 60 * 60 -# Well-known static test API key, seeded once into the KV store on first -# request (see _seed_tokens) so existing clients/tests that use it keep -# working while any other key is rejected with 401. This mirrors -# RevenueCat's public "sk_" SDK keys. +# Well-known static test API keys, seeded once into the KV store on first +# request (see _seed_tokens) so existing clients/tests that use them keep +# working while any other key is rejected with 401. This mirrors RevenueCat's +# key split: the "sk_" key is the project-wide SECRET key (any request); the +# "pk_" key is the app-specific PUBLIC SDK key (subscriber reads + receipt +# posts only — restricted actions need the secret key, like the real API). _TEST_API_KEY = "sk_test_revenuecat_style_mock_key" +_TEST_PUBLIC_KEY = "pk_test_revenuecat_style_mock_key" -# _seed_tokens inserts the well-known test key into the KV store exactly once -# per instance (guarded by the "auth_seeded" flag), stored under +# _seed_tokens inserts the well-known test keys into the KV store exactly +# once per instance (guarded by the "auth_seeded" flag): secrets under # "tok:" with a far-future expiry computed at runtime (never a -# hardcoded epoch). +# hardcoded epoch), publics under "pub:". def _seed_tokens(): if store_kv_get("revenuecat", "auth_seeded") == "yes": return store_kv_set("revenuecat", "auth_seeded", "yes") exp = str(clock.now_unix() + 3600 * 24 * 365 * 10) store_kv_set("revenuecat", "tok:" + _TEST_API_KEY, exp) + store_kv_set("revenuecat", "pub:" + _TEST_PUBLIC_KEY, "yes") # _bearer extracts the token from an "Authorization: Bearer " header. # Returns "" if the header is absent or not a Bearer header. @@ -38,23 +42,43 @@ def _bearer(req): return auth[7:] return "" +# _key_kind classifies a bearer key: "secret" (known + unexpired), "public", +# or "" (unknown or expired). +def _key_kind(token): + if token == "": + return "" + _seed_tokens() + exp = store_kv_get("revenuecat", "tok:" + token) + if exp != None and clock.now_unix() <= _to_int(exp): + return "secret" + if store_kv_get("revenuecat", "pub:" + token) == "yes": + return "public" + return "" + # _require_auth validates the Authorization: Bearer header against the -# KV token store. Returns None if the key is known and unexpired, or a 401 -# error-response dict if missing, malformed, unknown, or expired. -def _require_auth(req): +# KV token store with the real key split: secret "sk_" keys may make any +# request; public "pk_" SDK keys only the SDK-facing ones. secret_only marks +# the restricted endpoints (subscriber writes, deletes, revoke, webhook +# management) where a public key is a 401. Returns None if the key is +# acceptable, else a 401 error-response dict. +def _require_auth(req, secret_only = False): token = _bearer(req) if token == "": return respond(401, { "code": 401, "message": "Missing API key in Authorization header.", }) - _seed_tokens() - exp = store_kv_get("revenuecat", "tok:" + token) - if exp == None or clock.now_unix() > _to_int(exp): + kind = _key_kind(token) + if kind == "": return respond(401, { "code": 401, "message": "Invalid API key.", }) + if kind == "public" and secret_only: + return respond(401, { + "code": 401, + "message": "This endpoint requires a secret API key.", + }) return None # ============================================================================ @@ -180,10 +204,16 @@ def _strip_map_scalars(m): out[k] = m[k] return out -# _subscriber_response wraps the public view in RevenueCat's response shape: -# {subscriber: {...}}. +# _subscriber_response wraps the public view in RevenueCat's v1 CustomerInfo +# response shape: {request_date, request_date_ms, subscriber: {...}} — the +# envelope the SDKs parse, timestamps minted from the engine clock. def _subscriber_response(doc): - return respond(200, {"subscriber": _subscriber_view(doc)}) + now = clock.now_unix() + return respond(200, { + "request_date": clock.unix_to_rfc3339(now), + "request_date_ms": now * 1000, + "subscriber": _subscriber_view(doc), + }) # ============================================================================ # EXPIRATION (derive-on-read state machine) diff --git a/adapters/revenuecat-style/scripts/receipts.star b/adapters/revenuecat-style/scripts/receipts.star index 2c9c1d9a..d75bebe7 100644 --- a/adapters/revenuecat-style/scripts/receipts.star +++ b/adapters/revenuecat-style/scripts/receipts.star @@ -27,7 +27,9 @@ # _apply_purchase, _refresh_subscriber, _subscriber_response) are preloaded # from scripts/lib.star. -# on_post_receipt validates a receipt and applies the purchase. +# on_post_receipt validates a receipt and applies the purchase. SDK-facing: +# the public "pk_" key is accepted alongside the secret one (the real +# endpoint is what the mobile SDK posts receipts with). def on_post_receipt(req): err = _require_auth(req) if err != None: diff --git a/adapters/revenuecat-style/scripts/subscribers.star b/adapters/revenuecat-style/scripts/subscribers.star index e894eb44..27580c99 100644 --- a/adapters/revenuecat-style/scripts/subscribers.star +++ b/adapters/revenuecat-style/scripts/subscribers.star @@ -34,7 +34,7 @@ def on_get_subscriber(req): # into subscriber.attributes and optionally seeds entitlements / # subscriptions / non_subscriptions. Returns the subscriber state. def on_post_subscriber(req): - err = _require_auth(req) + err = _require_auth(req, True) # subscriber writes are secret-key only if err != None: return err @@ -79,7 +79,7 @@ def on_post_subscriber(req): # on_delete_subscriber deletes the subscriber (RevenueCat's # DELETE /v1/subscribers/{app_user_id}). def on_delete_subscriber(req): - err = _require_auth(req) + err = _require_auth(req, True) # deleting subscribers is restricted to secret keys if err != None: return err @@ -97,7 +97,7 @@ def on_delete_subscriber(req): # on_revoke_subscription refunds a subscription (RevenueCat v2-shaped revoke): # lapses it immediately, drops the entitlement it granted, fires CANCELLATION. def on_revoke_subscription(req): - err = _require_auth(req) + err = _require_auth(req, True) # revoke is a server-side restricted action if err != None: return err diff --git a/adapters/revenuecat-style/scripts/webhooks.star b/adapters/revenuecat-style/scripts/webhooks.star index cccb6744..40dfa7ea 100644 --- a/adapters/revenuecat-style/scripts/webhooks.star +++ b/adapters/revenuecat-style/scripts/webhooks.star @@ -14,7 +14,7 @@ # on_create_webhook registers a webhook subscription. def on_create_webhook(req): - err = _require_auth(req) + err = _require_auth(req, True) # management surface: secret keys only if err != None: return err @@ -61,7 +61,7 @@ def on_create_webhook(req): # on_list_webhooks returns registered webhook subscriptions. def on_list_webhooks(req): - err = _require_auth(req) + err = _require_auth(req, True) # management surface: secret keys only if err != None: return err diff --git a/adapters/revenuecat_style_test.go b/adapters/revenuecat_style_test.go new file mode 100644 index 00000000..fa9b8177 --- /dev/null +++ b/adapters/revenuecat_style_test.go @@ -0,0 +1,378 @@ +package adapters + +import ( + "os" + "path/filepath" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the revenuecat-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: the secret sk_ / public pk_ Bearer +// key gates, the get-or-create subscriber behind the v1 CustomerInfo +// envelope, receipt validation + real expiry math, derive-on-read +// EXPIRATION state, revoke, deletion, and the {code, message} error +// envelopes. +const ( + rcSecret = "Bearer sk_test_revenuecat_style_mock_key" + rcPublic = "Bearer pk_test_revenuecat_style_mock_key" +) + +type rcFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + host string +} + +func newRCFixture(t *testing.T, start time.Time) *rcFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "revenuecat-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, err := primitives.Open(filepath.Join(tmp, "s.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + kvStore, err := kv.Open(filepath.Join(tmp, "s.kv.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { kvStore.Close() }) + blobStore, err := blob.Open(filepath.Join(tmp, "blobs")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &rcFixture{t: t, vc: vc, host: "api.revenuecat.test", vms: map[string]*starlark.VM{ + "receipts": load("receipts.star"), "subs": load("subscribers.star"), + "hooks": load("webhooks.star"), + }} +} + +func (f *rcFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + return f.callHdr(group, handler, method, path, params, body, headers) +} + +// callHdr drives a handler with full control over request headers (needed +// for the receipts endpoint's X-Platform mechanism). +func (f *rcFixture) callHdr(group, handler, method, path string, params map[string]string, body map[string]any, headers map[string]string) starlark.Response { + f.t.Helper() + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// rcNum coerces a Starlark-round-tripped JSON number to int64. +func rcNum(t *testing.T, v any) int64 { + t.Helper() + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + case float64: + return int64(n) + } + t.Fatalf("value %v (%T) is not a number", v, v) + return 0 +} + +// rcSubscriber unwraps the {request_date, request_date_ms, subscriber} +// CustomerInfo envelope, asserting a 200. +func rcSubscriber(t *testing.T, r starlark.Response) map[string]any { + t.Helper() + if r.Status != 200 { + t.Fatalf("want 200, got %d: %v", r.Status, r.Body) + } + s, ok := r.Body["subscriber"].(map[string]any) + if !ok { + t.Fatalf("subscriber = %v (%T), want a map", r.Body["subscriber"], r.Body["subscriber"]) + } + return s +} + +func TestRevenueCatKeyGates(t *testing.T) { + base := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC) + f := newRCFixture(t, base) + get := func(id, auth string) starlark.Response { + return f.call("subs", "on_get_subscriber", "GET", "/v1/subscribers/"+id, + map[string]string{"app_user_id": id}, nil, nil, auth) + } + + // ===== a missing or unknown key is a 401 {code, message} envelope ===== + // No header and an unknown key are both 401s with the RC-style envelope. + if r := get("user-1", ""); r.Status != 401 || rcNum(t, r.Body["code"]) != 401 || + r.Body["message"] != "Missing API key in Authorization header." { + t.Fatalf("no auth -> %d %v, want 401 missing-key envelope", r.Status, r.Body) + } + if r := get("user-1", "Bearer sk_nope"); r.Status != 401 || rcNum(t, r.Body["code"]) != 401 || + r.Body["message"] != "Invalid API key." { + t.Fatalf("unknown key -> %d %v, want 401 invalid-key envelope", r.Status, r.Body) + } + + // ===== the public pk_ SDK key passes subscriber reads and receipt posts but is 401 on restricted writes ===== + // Real RevenueCat: public app keys are SDK-facing (reads + receipts); + // deletes/revokes/subscriber-writes/webhook management need the sk_ secret. + if r := get("user-p1", rcPublic); r.Status != 200 { + t.Fatalf("public key GET subscriber -> %d %v, want 200", r.Status, r.Body) + } + if r := f.callHdr("receipts", "on_post_receipt", "POST", "/v1/receipts", nil, + map[string]any{"app_user_id": "user-p1", "fetch_token": "fake_receipt_token", "product_id": "premium"}, + map[string]string{"Authorization": rcPublic, "X-Platform": "ios"}); r.Status != 200 { + t.Fatalf("public key POST receipts -> %d %v, want 200", r.Status, r.Body) + } + if s := rcSubscriber(t, get("user-p1", rcSecret)); len(s["entitlements"].(map[string]any)) != 1 { + t.Fatalf("entitlements after public-key receipt = %v, want the granted one", s["entitlements"]) + } + const wantGate = "This endpoint requires a secret API key." + if r := f.call("subs", "on_post_subscriber", "POST", "/v1/subscribers/user-p1", + map[string]string{"app_user_id": "user-p1"}, nil, + map[string]any{"attributes": map[string]any{"$displayName": "Alex"}}, rcPublic); r.Status != 401 || r.Body["message"] != wantGate { + t.Fatalf("public key POST subscriber -> %d %v, want 401 secret-required", r.Status, r.Body) + } + if r := f.call("subs", "on_delete_subscriber", "DELETE", "/v1/subscribers/user-p1", + map[string]string{"app_user_id": "user-p1"}, nil, nil, rcPublic); r.Status != 401 || r.Body["message"] != wantGate { + t.Fatalf("public key DELETE subscriber -> %d %v, want 401 secret-required", r.Status, r.Body) + } + if r := f.call("subs", "on_revoke_subscription", "POST", "/v1/subscribers/user-p1/subscriptions/premium/revoke", + map[string]string{"app_user_id": "user-p1", "product_id": "premium"}, nil, + map[string]any{"reason": "refund"}, rcPublic); r.Status != 401 || r.Body["message"] != wantGate { + t.Fatalf("public key revoke -> %d %v, want 401 secret-required", r.Status, r.Body) + } + if r := f.call("hooks", "on_create_webhook", "POST", "/v1/webhooks", nil, nil, + map[string]any{"url": "https://sink.example.test/hook"}, rcPublic); r.Status != 401 || r.Body["message"] != wantGate { + t.Fatalf("public key webhooks -> %d %v, want 401 secret-required", r.Status, r.Body) + } + // The same DELETE with the secret key succeeds: the 401s above were the + // gate, not the state. + if r := f.call("subs", "on_delete_subscriber", "DELETE", "/v1/subscribers/user-p1", + map[string]string{"app_user_id": "user-p1"}, nil, nil, rcSecret); r.Status != 200 { + t.Fatalf("secret key DELETE subscriber -> %d %v, want 200", r.Status, r.Body) + } + + // ===== GET subscriber is get-or-create and answers in the v1 CustomerInfo envelope ===== + // An unknown app_user_id is created empty (real RC get-or-create); the + // envelope carries request_date/request_date_ms plus the public maps. + r := get("user-env", rcSecret) + if r.Body["request_date"] != base.Format(time.RFC3339) { + t.Fatalf("request_date = %v, want %s", r.Body["request_date"], base.Format(time.RFC3339)) + } + if got := rcNum(t, r.Body["request_date_ms"]); got != base.Unix()*1000 { + t.Fatalf("request_date_ms = %d, want %d", got, base.Unix()*1000) + } + s := rcSubscriber(t, r) + if s["original_app_user_id"] != "user-env" || s["first_seen"] != base.Format(time.RFC3339) { + t.Fatalf("subscriber identity = %v / %v", s["original_app_user_id"], s["first_seen"]) + } + for _, m := range []string{"entitlements", "subscriptions", "non_subscriptions", "attributes"} { + if mm, ok := s[m].(map[string]any); !ok || len(mm) != 0 { + t.Fatalf("default %s = %v (%T), want an empty map", m, s[m], s[m]) + } + } +} + +func TestRevenueCatReceiptsLifecycleAndDeletion(t *testing.T) { + base := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC) + f := newRCFixture(t, base) + get := func(id string) starlark.Response { + return f.call("subs", "on_get_subscriber", "GET", "/v1/subscribers/"+id, + map[string]string{"app_user_id": id}, nil, nil, rcSecret) + } + receipt := func(body map[string]any, headers map[string]string) starlark.Response { + h := map[string]string{"Authorization": rcSecret} + for k, v := range headers { + h[k] = v + } + return f.callHdr("receipts", "on_post_receipt", "POST", "/v1/receipts", nil, body, h) + } + + // ===== receipt validation mirrors the real 400 order: app_user_id, platform, fetch_token, bad token ===== + // Each failure is a 400 {code, message} in the documented check order. + if r := receipt(map[string]any{"fetch_token": "t", "platform": "ios", "product_id": "premium"}, nil); r.Status != 400 || + r.Body["message"] != "app_user_id is required" { + t.Fatalf("no app_user_id -> %d %v, want 400 app_user_id is required", r.Status, r.Body) + } + if r := receipt(map[string]any{"app_user_id": "u0", "fetch_token": "t", "product_id": "premium"}, nil); r.Status != 400 || + r.Body["message"] != "X-Platform header (or platform field) is required: ios or android" { + t.Fatalf("no platform -> %d %v, want 400 platform is required", r.Status, r.Body) + } + if r := receipt(map[string]any{"app_user_id": "u0", "fetch_token": "t", "platform": "webos"}, nil); r.Status != 400 || + r.Body["message"] != "Invalid platform: must be one of [ios, android]" { + t.Fatalf("bad platform -> %d %v, want 400 invalid platform", r.Status, r.Body) + } + if r := receipt(map[string]any{"app_user_id": "u0", "platform": "ios", "product_id": "premium"}, nil); r.Status != 400 || + r.Body["message"] != "fetch_token is required" { + t.Fatalf("no fetch_token -> %d %v, want 400 fetch_token is required", r.Status, r.Body) + } + if r := receipt(map[string]any{"app_user_id": "u0", "platform": "ios", "product_id": "premium", "fetch_token": "invalid_receipt"}, nil); r.Status != 400 || + r.Body["message"] != "There was an error fetching the receipt" { + t.Fatalf("invalid receipt -> %d %v, want 400 error fetching the receipt", r.Status, r.Body) + } + // The failed validations must not have created any subscriber. + if s := rcSubscriber(t, get("u0")); len(s["entitlements"].(map[string]any)) != 0 { + t.Fatalf("entitlements after failed receipts = %v, want empty", s["entitlements"]) + } + + // ===== an ios receipt grants the pro entitlement with real trial math, and renewals stack ===== + // First purchase of premium (7-day intro trial): entitlement + + // subscription in the RC v1 schema, via the X-Platform header like the + // real API. + r := receipt(map[string]any{"app_user_id": "user-2", "fetch_token": "fake_receipt_token", "product_id": "premium"}, + map[string]string{"X-Platform": "ios"}) + s := rcSubscriber(t, r) + pro, ok := s["entitlements"].(map[string]any)["pro"].(map[string]any) + if !ok { + t.Fatalf("pro entitlement = %v, want present", s["entitlements"]) + } + trialEnd := base.Add(7 * 24 * time.Hour) + if pro["expires_date"] != trialEnd.Format(time.RFC3339) || pro["product_identifier"] != "premium" || + pro["purchase_date"] != base.Format(time.RFC3339) { + t.Fatalf("pro entitlement = %v, want premium trial to %s", pro, trialEnd.Format(time.RFC3339)) + } + prem, ok := s["subscriptions"].(map[string]any)["premium"].(map[string]any) + if !ok { + t.Fatalf("subscriptions[premium] = %v, want present", s["subscriptions"]) + } + if prem["period_type"] != "TRIAL" || prem["store"] != "app_store" || prem["is_active"] != true || + prem["expires_date"] != trialEnd.Format(time.RFC3339) { + t.Fatalf("trial subscription = %v, want TRIAL/app_store/active to %s", prem, trialEnd.Format(time.RFC3339)) + } + for _, m := range []map[string]any{pro, prem} { + for k := range m { + if len(k) > 0 && k[0] == '_' { + t.Fatalf("internal key %q leaked into the subscriber view: %v", k, m) + } + } + } + // The same receipt again is a renewal: NORMAL period stacked onto the + // unexpired trial (expires = trial end + 30d), original purchase kept. + renewEnd := trialEnd.Add(30 * 24 * time.Hour) + s = rcSubscriber(t, receipt(map[string]any{"app_user_id": "user-2", "fetch_token": "fake_receipt_token", "product_id": "premium"}, + map[string]string{"X-Platform": "ios"})) + prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any) + if prem["period_type"] != "NORMAL" || prem["expires_date"] != renewEnd.Format(time.RFC3339) || + prem["original_purchase_date"] != base.Format(time.RFC3339) { + t.Fatalf("renewed subscription = %v, want NORMAL stacked to %s", prem, renewEnd.Format(time.RFC3339)) + } + + // ===== a google-play dict receipt feeds the product and lands in non_subscriptions ===== + // android via the body platform field; the purchaseToken dict's productId + // picks the non-subscription product. + s = rcSubscriber(t, receipt(map[string]any{ + "app_user_id": "user-3", + "platform": "android", + "fetch_token": map[string]any{"purchaseToken": "google_purchase_token_1", "productId": "gold_coins", "orderId": "GPA-order-1"}, + }, nil)) + nons, ok := s["non_subscriptions"].(map[string]any)["gold_coins"].([]any) + if !ok || len(nons) != 1 { + t.Fatalf("non_subscriptions[gold_coins] = %v, want one purchase record", s["non_subscriptions"]) + } + rec, _ := nons[0].(map[string]any) + if rec["product_id"] != "gold_coins" || rec["id"] == "" || rec["purchase_date"] != base.Format(time.RFC3339) { + t.Fatalf("non-subscription record = %v, want gold_coins rc_* purchase at base", rec) + } + if ents := s["entitlements"].(map[string]any); len(ents) != 0 { + t.Fatalf("entitlements after consumable = %v, want none", ents) + } + + // ===== revoke lapses a live subscription; delete and the 404 envelopes ===== + // Revoke refunds user-5's live trial; unknown subscriber/product and + // double deletes are the 404 {code, message} shapes. + rcSubscriber(t, receipt(map[string]any{"app_user_id": "user-5", "fetch_token": "fake_receipt_token", "product_id": "premium"}, + map[string]string{"X-Platform": "ios"})) + revoke := func(id, product string) starlark.Response { + return f.call("subs", "on_revoke_subscription", "POST", "/v1/subscribers/"+id+"/subscriptions/"+product+"/revoke", + map[string]string{"app_user_id": id, "product_id": product}, nil, + map[string]any{"reason": "refund"}, rcSecret) + } + s = rcSubscriber(t, revoke("user-5", "premium")) + if _, still := s["entitlements"].(map[string]any)["pro"]; still { + t.Fatalf("pro entitlement still present after revoke: %v", s["entitlements"]) + } + prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any) + if prem["is_active"] != false || rcNum(t, prem["auto_renewal_status"]) != 0 || + prem["expires_date"] != base.Format(time.RFC3339) { + t.Fatalf("revoked subscription = %v, want inactive/auto-renew-off lapsed at base", prem) + } + if r := revoke("user-5", "nope"); r.Status != 404 || rcNum(t, r.Body["code"]) != 404 || + r.Body["message"] != "Subscription not found" { + t.Fatalf("revoke unknown product -> %d %v, want 404 Subscription not found", r.Status, r.Body) + } + if r := revoke("ghost", "premium"); r.Status != 404 || r.Body["message"] != "Subscriber not found" { + t.Fatalf("revoke unknown subscriber -> %d %v, want 404 Subscriber not found", r.Status, r.Body) + } + del := func(id string) starlark.Response { + return f.call("subs", "on_delete_subscriber", "DELETE", "/v1/subscribers/"+id, + map[string]string{"app_user_id": id}, nil, nil, rcSecret) + } + if r := del("user-5"); r.Status != 200 { + t.Fatalf("DELETE subscriber -> %d %v, want 200", r.Status, r.Body) + } + if r := del("user-5"); r.Status != 404 || rcNum(t, r.Body["code"]) != 404 || + r.Body["message"] != "Subscriber not found" { + t.Fatalf("second DELETE -> %d %v, want 404 Subscriber not found", r.Status, r.Body) + } + // A later GET recreates the subscriber empty, like the real API. + if s = rcSubscriber(t, get("user-5")); len(s["entitlements"].(map[string]any)) != 0 || + s["original_app_user_id"] != "user-5" { + t.Fatalf("subscriber after delete+recreate = %v, want empty but same identity", s) + } + + // ===== expiry is derived on read: a lapsed trial drops its entitlement ===== + // Past the trial end the first reader observes the lapse: entitlement + // gone, subscription marked inactive, and stable on a second read. + rcSubscriber(t, receipt(map[string]any{"app_user_id": "user-4", "fetch_token": "fake_receipt_token", "product_id": "premium"}, + map[string]string{"X-Platform": "ios"})) + f.vc.Advance(7*24*time.Hour + time.Hour) + s = rcSubscriber(t, get("user-4")) + if _, still := s["entitlements"].(map[string]any)["pro"]; still { + t.Fatalf("pro entitlement still present after lapse: %v", s["entitlements"]) + } + prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any) + if prem["is_active"] != false || prem["expires_date"] != trialEnd.Format(time.RFC3339) { + t.Fatalf("lapsed subscription = %v, want inactive with expires_date %s", prem, trialEnd.Format(time.RFC3339)) + } + s = rcSubscriber(t, get("user-4")) + if prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any); prem["is_active"] != false { + t.Fatalf("second read flipped the lapsed subscription: %v", prem) + } +} diff --git a/adapters/sendgrid-style/scripts/webhooks.star b/adapters/sendgrid-style/scripts/webhooks.star index 2a4ff393..4ed938c4 100644 --- a/adapters/sendgrid-style/scripts/webhooks.star +++ b/adapters/sendgrid-style/scripts/webhooks.star @@ -31,6 +31,19 @@ def on_update_settings(req): if url == None: url = "" + # Real SendGrid requires the URL when the webhook is enabled; without + # this guard the flag would say on while deliveries silently go nowhere. + if enabled and url == "": + return respond(400, { + "errors": [ + { + "message": "The event webhook URL is required when the webhook is enabled.", + "field": "url", + "help": None, + } + ], + }) + flag = "no" if enabled: flag = "yes" diff --git a/adapters/sendgrid_style_test.go b/adapters/sendgrid_style_test.go new file mode 100644 index 00000000..16114bac --- /dev/null +++ b/adapters/sendgrid_style_test.go @@ -0,0 +1,505 @@ +package adapters + +import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the sendgrid-style adapter scripts directly (lib.star preloaded) +// over a shared store, a virtual clock and a real local webhook sink: the +// bearer gate, the v3 mail send contract (202 empty body, X-Message-Id, +// personalizations, sandbox/asm tolerance), the derive-on-read processed -> +// delivered / dropped lifecycle, the Email Activity query filters, the event +// webhook settings round-trip, and the ECDSA P-256 signed deliveries. +const sgAuth = "Bearer SG.testkey.testsecret" + +// sgKeyPEM is the Event Webhook verification key published in the adapter +// README (the fixed synthetic P-256 key the simulator signs with). +const sgKeyPEM = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0WzqFnJjT+5g+V+kv4PvLa+f4+vD +V+AZ2Z+v257zCF9pOXvJU3unksixtekc1Sv4HD6MOXXpus0tODGWgMAMEQ== +-----END PUBLIC KEY-----` + +// sgDelivery is one captured outbound Event Webhook delivery. +type sgDelivery struct { + body string + headers http.Header +} + +type sgFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + mu sync.Mutex + sink []sgDelivery + sinkURL string +} + +func newSgFixture(t *testing.T, start time.Time) *sgFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "sendgrid-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + f := &sgFixture{t: t, vc: vc} + // Real (local) sink so the signed Event Webhook deliveries can be + // captured — the same emitter the engine hands handlers. + em := events.NewEmitter() + t.Cleanup(em.Close) + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.sink = append(f.sink, sgDelivery{body: string(b), headers: r.Header.Clone()}) + f.mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(sink.Close) + f.sinkURL = sink.URL + + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em, + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + f.vms = map[string]*starlark.VM{ + "mail": load("mail.star"), "hooks": load("webhooks.star"), + } + return f +} + +func (f *sgFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: "api.sendgrid.test", Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// send POSTs one mail send body and returns the 202 response. +func (f *sgFixture) send(body map[string]any) starlark.Response { + f.t.Helper() + r := f.call("mail", "on_send_mail", "POST", "/v3/mail/send", nil, nil, body, sgAuth) + if r.Status != 202 { + f.t.Fatalf("send mail -> %d: %v", r.Status, r.Body) + } + return r +} + +// messages lists sent mail, applying optional Email Activity query params. +func (f *sgFixture) messages(query map[string]string) map[string]any { + f.t.Helper() + r := f.call("mail", "on_list_messages", "GET", "/v3/messages", nil, query, nil, sgAuth) + if r.Status != 200 { + f.t.Fatalf("list messages %v -> %d: %v", query, r.Status, r.Body) + } + return r.Body +} + +func (f *sgFixture) deliveries() []sgDelivery { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]sgDelivery, len(f.sink)) + copy(out, f.sink) + return out +} + +func TestSendgridAuthAndMailSend(t *testing.T) { + f := newSgFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + + // ===== the bearer gate rejects missing and unknown keys with SendGrid's grant envelope ===== + for _, tc := range []struct { + name string + auth string + }{ + {"no authorization", ""}, + {"unknown key", "Bearer SG.bogus.wrongkey"}, + {"non-bearer", "Basic dXNlcjpwYXNz"}, + } { + r := f.call("mail", "on_list_messages", "GET", "/v3/messages", nil, nil, nil, tc.auth) + if r.Status != 401 { + t.Fatalf("messages with %s -> %d, want 401", tc.name, r.Status) + } + errs, _ := r.Body["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("messages with %s: errors = %v, want the single-entry envelope", tc.name, r.Body["errors"]) + } + e0, _ := errs[0].(map[string]any) + if e0["message"] != "The provided authorization grant is invalid, expired, or revoked." { + t.Fatalf("messages with %s: message = %v", tc.name, e0["message"]) + } + if e0["field"] != nil || e0["help"] != nil { + t.Fatalf("messages with %s: field/help = %v / %v, want null/null", tc.name, e0["field"], e0["help"]) + } + } + if r := f.call("mail", "on_send_mail", "POST", "/v3/mail/send", nil, nil, map[string]any{ + "personalizations": []any{map[string]any{"to": []any{map[string]any{"email": "x@example.test"}}}}, + "from": map[string]any{"email": "y@example.test"}, + }, ""); r.Status != 401 { + t.Fatalf("send without auth -> %d, want 401", r.Status) + } + + // ===== mail send answers 202 with an empty body, an X-Message-Id, and flattened personalizations ===== + // Exactly the real v3 contract: accepted, nothing to read back; the + // message itself shows up on the retrieval endpoint with one entry per + // recipient and the personalization subject winning over the top-level. + r := f.send(map[string]any{ + "personalizations": []any{ + map[string]any{"to": []any{map[string]any{"email": "a@example.test"}}, "subject": "Alpha"}, + map[string]any{"to": []any{map[string]any{"email": "b@example.test"}}, "subject": "Beta"}, + }, + "subject": "ignored-top-level", + "from": map[string]any{"email": "sender@example.test", "name": "VM Suite"}, + "content": []any{map[string]any{"type": "text/plain", "value": "hello"}}, + }) + if r.RawBody != "" || len(r.Body) != 0 { + t.Fatalf("send body = %q / %v, want empty (202 with no body, like real SendGrid)", r.RawBody, r.Body) + } + if r.Headers["X-Message-Id"] != "msg_1@stunt.local" { + t.Fatalf("send X-Message-Id = %q, want msg_1@stunt.local", r.Headers["X-Message-Id"]) + } + if r.Headers["Access-Control-Allow-Origin"] != "https://sendgrid.com" { + t.Fatalf("send Access-Control-Allow-Origin = %q", r.Headers["Access-Control-Allow-Origin"]) + } + f.send(map[string]any{ + "personalizations": []any{ + map[string]any{"to": []any{map[string]any{"email": "c@example.test"}}, "subject": "Gamma"}, + }, + "from": map[string]any{"email": "sender@example.test"}, + }) + listed := f.messages(nil) + msgs, _ := listed["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("messages = %d, want the 2 sent", len(msgs)) + } + var alpha map[string]any + for _, m := range msgs { + mm, _ := m.(map[string]any) + if mm["subject"] == "Alpha" { + alpha = mm + } + } + if alpha == nil { + t.Fatalf("no Alpha message in %v", msgs) + } + if alpha["subject"] != "Alpha" || alpha["status"] != "processed" { + t.Fatalf("alpha subject/status = %v / %v, want Alpha / processed", alpha["subject"], alpha["status"]) + } + if id, _ := alpha["id"].(string); id != "msg_1@stunt.local" { + t.Fatalf("alpha id = %q, want the send's X-Message-Id", id) + } + to, _ := alpha["to"].([]any) + if len(to) != 2 || to[0].(map[string]any)["email"] != "a@example.test" || to[1].(map[string]any)["email"] != "b@example.test" { + t.Fatalf("alpha to = %v, want one entry per recipient across personalizations", alpha["to"]) + } + if from, _ := alpha["from"].(map[string]any); from["email"] != "sender@example.test" || from["name"] != "VM Suite" { + t.Fatalf("alpha from = %v, want the sender object round-tripped", alpha["from"]) + } + if alpha["created_at"] != "2024-01-15T12:00:00Z" { + t.Fatalf("alpha created_at = %v (the adapter's stable synthetic stamp)", alpha["created_at"]) + } + + // asm / mail_settings.sandbox_mode are not modeled: the send is accepted + // and stored like any other (real SendGrid would validate-only under + // sandbox mode) — asserted as-is, see the deviation report. + sandbox := f.send(map[string]any{ + "personalizations": []any{ + map[string]any{"to": []any{map[string]any{"email": "d@example.test"}}, "subject": "Sandboxed"}, + }, + "from": map[string]any{"email": "sender@example.test"}, + "asm": map[string]any{"group_id": 42, "groups_to_display": []any{7}}, + "mail_settings": map[string]any{ + "sandbox_mode": map[string]any{"enable": true}, + "bypass_list_management": map[string]any{"enable": true}, + }, + "batch_id": "ZGD7JxuLTEJmG9yzSYpVAg", + }) + if sandbox.Status != 202 { + t.Fatalf("sandboxed send -> %d, want 202 (asm/mail_settings tolerated)", sandbox.Status) + } + if got := len(f.messages(nil)["messages"].([]any)); got != 3 { + t.Fatalf("messages after sandboxed send = %d, want 3 (stored like any other)", got) + } + + // ===== the retrieval endpoint pages with limit and the opaque offset cursor ===== + p1 := f.messages(map[string]string{"limit": "2"}) + if got := len(p1["messages"].([]any)); got != 2 { + t.Fatalf("limit=2 page = %d messages, want 2", got) + } + next, _ := p1["next_offset"].(string) + if next != "2" { + t.Fatalf("limit=2 next_offset = %q, want 2", next) + } + p2 := f.messages(map[string]string{"limit": "2", "offset": next}) + if got := len(p2["messages"].([]any)); got != 1 { + t.Fatalf("offset=2 page = %d messages, want the remaining 1", got) + } + if _, has := p2["next_offset"]; has { + t.Fatalf("last page carries next_offset = %v", p2["next_offset"]) + } + if r := f.call("mail", "on_list_messages", "GET", "/v3/messages", nil, + map[string]string{"limit": "2", "offset": "not-a-cursor"}, nil, sgAuth); r.Status != 400 { + t.Fatalf("invalid cursor -> %d %v, want 400", r.Status, r.Body) + } else if errs, _ := r.Body["errors"].([]any); len(errs) != 1 || errs[0].(map[string]any)["message"] != "Invalid cursor parameter." { + t.Fatalf("invalid cursor errors = %v, want Invalid cursor parameter.", r.Body["errors"]) + } +} + +func TestSendgridLifecycleAndEventWebhook(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newSgFixture(t, base) + + // ===== the delivery lifecycle derives processed -> delivered (or dropped) on read, exactly once ===== + // A send is accepted as processed; the terminal status lands on the first + // read past the 3s window, and failure injection selects dropped. + f.send(map[string]any{ + "personalizations": []any{ + map[string]any{"to": []any{map[string]any{"email": "reader@example.test"}}, "subject": "welcome"}, + }, + "from": map[string]any{"email": "sender@example.test"}, + }) + f.send(map[string]any{ + "personalizations": []any{ + map[string]any{"to": []any{map[string]any{"email": "victim@example.test"}}, "subject": "doomed"}, + }, + "from": map[string]any{"email": "sender@example.test"}, + "simulate_fail": true, + }) + statuses := func() map[string]string { + out := map[string]string{} + for _, m := range f.messages(nil)["messages"].([]any) { + mm, _ := m.(map[string]any) + out[mm["subject"].(string)] = mm["status"].(string) + } + return out + } + if s := statuses(); s["welcome"] != "processed" || s["doomed"] != "processed" { + t.Fatalf("statuses at t=0 = %v, want both processed", s) + } + if got := len(f.deliveries()); got != 0 { + t.Fatalf("deliveries before any webhook is enabled = %d, want 0", got) + } + f.vc.Advance(4 * time.Second) // past the 3s terminal window + if s := statuses(); s["welcome"] != "delivered" || s["doomed"] != "dropped" { + t.Fatalf("statuses at 4s = %v, want delivered / dropped", s) + } + if s := statuses(); s["welcome"] != "delivered" || s["doomed"] != "dropped" { + t.Fatalf("statuses re-read = %v, want the terminal states to persist", s) + } + + // ===== the Email Activity query language narrows the list ===== + one := func(query string) []string { + t.Helper() + var subjects []string + for _, m := range f.messages(map[string]string{"query": query})["messages"].([]any) { + mm, _ := m.(map[string]any) + subjects = append(subjects, mm["subject"].(string)) + } + return subjects + } + if got := one(`subject CONTAINS "wel"`); len(got) != 1 || got[0] != "welcome" { + t.Fatalf(`subject CONTAINS "wel" = %v, want [welcome]`, got) + } + if got := one(`status="delivered"`); len(got) != 1 || got[0] != "welcome" { + t.Fatalf(`status="delivered" = %v, want [welcome]`, got) + } + if got := one(`to_email="victim@example.test"`); len(got) != 1 || got[0] != "doomed" { + t.Fatalf(`to_email="victim@example.test" = %v, want [doomed]`, got) + } + if got := one(`from_email="sender@example.test" AND status!="delivered"`); len(got) != 1 || got[0] != "doomed" { + t.Fatalf(`from_email AND status!= = %v, want [doomed]`, got) + } + if got := one(`msg_id="msg_1@stunt.local"`); len(got) != 1 || got[0] != "welcome" { + t.Fatalf(`msg_id= = %v, want [welcome]`, got) + } + if got := one(`not_a_field="welcome"`); len(got) != 2 { + t.Fatalf("unrecognized field term = %v, want the whole list (term ignored)", got) + } + + // ===== event webhook settings round-trip and require a URL when enabled ===== + settings := f.call("hooks", "on_get_settings", "GET", "/v3/user/webhooks/event/settings", nil, nil, nil, sgAuth) + if settings.Status != 200 || settings.Body["enabled"] != false || settings.Body["url"] != "" { + t.Fatalf("default settings -> %d %v, want disabled with an empty url", settings.Status, settings.Body) + } + // Enabling without a URL is the documented-required-field 400 (real + // SendGrid requires url with enabled); the test event is 400 until a + // URL is configured too. + if r := f.call("hooks", "on_update_settings", "POST", "/v3/user/webhooks/event/settings", nil, nil, + map[string]any{"enabled": true}, sgAuth); r.Status != 400 { + t.Fatalf("enable without url -> %d %v, want 400", r.Status, r.Body) + } + if r := f.call("hooks", "on_send_test_event", "POST", "/v3/user/webhooks/event/test", nil, nil, nil, sgAuth); r.Status != 400 { + t.Fatalf("test event before enabling -> %d %v, want 400", r.Status, r.Body) + } + updated := f.call("hooks", "on_update_settings", "POST", "/v3/user/webhooks/event/settings", nil, nil, + map[string]any{"enabled": true, "url": f.sinkURL}, sgAuth) + if updated.Status != 200 || updated.Body["enabled"] != true || updated.Body["url"] != f.sinkURL { + t.Fatalf("enable with url -> %d %v", updated.Status, updated.Body) + } + echoed := f.call("hooks", "on_get_settings", "GET", "/v3/user/webhooks/event/settings", nil, nil, nil, sgAuth) + if echoed.Body["enabled"] != true || echoed.Body["url"] != f.sinkURL || echoed.Body["friendly_name"] != "stunt event webhook" { + t.Fatalf("echoed settings = %v", echoed.Body) + } + test := f.call("hooks", "on_send_test_event", "POST", "/v3/user/webhooks/event/test", nil, nil, nil, sgAuth) + if test.Status != 202 || test.RawBody != "" || test.Headers["X-Message-Id"] != "msg_test@stunt.local" { + t.Fatalf("test event -> %d %q %v, want 202 empty with the test message id", test.Status, test.RawBody, test.Headers) + } + dv := f.deliveries() + if len(dv) != 1 { + t.Fatalf("deliveries after test event = %d, want 1", len(dv)) + } + if kind, payload := sgEnvelope(t, dv[0]); kind != "processed" || payload["email"] != "test@example.com" || payload["sg_message_id"] != "msg_test@stunt.local" { + t.Fatalf("test delivery = %s %v, want the processed sample event", kind, payload) + } + + // ===== deliveries are ECDSA P-256 signed over timestamp + raw body and fire once per recipient stage ===== + // The signature is the raw r||s form over str(timestamp) + body (the + // adapter's one documented deviation from Twilio's DER encoding). + sentAt := f.vc.Now().Unix() + f.send(map[string]any{ + "personalizations": []any{ + map[string]any{"to": []any{map[string]any{"email": "reader@example.test"}}, "subject": "receipt"}, + }, + "from": map[string]any{"email": "billing@example.test"}, + }) + dv = f.deliveries() + if len(dv) != 2 { + t.Fatalf("deliveries after send = %d, want the test event + one processed", len(dv)) + } + kind, payload := sgEnvelope(t, dv[1]) + if kind != "processed" || payload["email"] != "reader@example.test" || payload["sg_message_id"] != "msg_3@stunt.local" { + t.Fatalf("processed delivery = %s %v", kind, payload) + } + sgNum(t, payload["timestamp"], float64(sentAt), "processed delivery timestamp (virtual clock)") + if payload["sg_event_id"] != "evt_2" { + t.Fatalf("processed sg_event_id = %v, want evt_2 (evt_1 was the test event)", payload["sg_event_id"]) + } + f.vc.Advance(4 * time.Second) + f.messages(nil) // the read that derives the terminal state + dv = f.deliveries() + if len(dv) != 3 { + t.Fatalf("deliveries after terminal read = %d, want the delivered event too", len(dv)) + } + if kind, payload := sgEnvelope(t, dv[2]); kind != "delivered" || payload["sg_message_id"] != "msg_3@stunt.local" { + t.Fatalf("terminal delivery = %s %v, want delivered for the receipt", kind, payload) + } + f.messages(nil) // re-reading does not re-emit + if got := len(f.deliveries()); got != 3 { + t.Fatalf("deliveries after re-read = %d, want still 3 (exactly once)", got) + } + for i, d := range f.deliveries() { + sgVerifyDelivery(t, d, i) + } +} + +// sgVerifyDelivery checks the ECDSA P-256 signature over timestamp + raw +// body against the published public key. The signature is the raw r||s form +// (64 bytes), not ASN.1 DER — the adapter's documented deviation — so the +// halves are split manually before ecdsa.Verify. +func sgVerifyDelivery(t *testing.T, d sgDelivery, i int) { + t.Helper() + sig, err := base64.StdEncoding.DecodeString(d.headers.Get("X-Twilio-Email-Event-Webhook-Signature")) + if err != nil || len(sig) != 64 { + t.Fatalf("delivery %d: signature decodes to %d bytes (err %v), want the 64-byte raw r||s form", i, len(sig), err) + } + ts := d.headers.Get("X-Twilio-Email-Event-Webhook-Timestamp") + if ts == "" { + t.Fatalf("delivery %d carries no X-Twilio-Email-Event-Webhook-Timestamp", i) + } + block, _ := pem.Decode([]byte(sgKeyPEM)) + if block == nil { + t.Fatalf("delivery %d: bad verification key PEM", i) + } + pubAny, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatalf("delivery %d: parse public key: %v", i, err) + } + pub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("delivery %d: key is %T, want ECDSA", i, pubAny) + } + digest := sha256.Sum256([]byte(ts + d.body)) + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(pub, digest[:], r, s) { + t.Fatalf("delivery %d: ECDSA signature does not verify over %q", i, ts+d.body) + } +} + +// sgEnvelope unpacks the {type, payload} delivery envelope. +func sgEnvelope(t *testing.T, d sgDelivery) (string, map[string]any) { + t.Helper() + var env struct { + Type string `json:"type"` + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal([]byte(d.body), &env); err != nil { + t.Fatalf("delivery body is not JSON: %v (body %s)", err, d.body) + } + return env.Type, env.Payload +} + +// sgNum compares a JSON number regardless of int64/float64 width (payloads +// round-trip through the emitter, where ints may come back floats). +func sgNum(t *testing.T, v any, want float64, what string) { + t.Helper() + switch n := v.(type) { + case int64: + if float64(n) != want { + t.Fatalf("%s = %d, want %v", what, n, want) + } + case float64: + if n != want { + t.Fatalf("%s = %v, want %v", what, n, want) + } + default: + t.Fatalf("%s is %T(%v), want number %v", what, v, v, want) + } +} diff --git a/adapters/signin_with_apple_style_test.go b/adapters/signin_with_apple_style_test.go new file mode 100644 index 00000000..97749a09 --- /dev/null +++ b/adapters/signin_with_apple_style_test.go @@ -0,0 +1,411 @@ +package adapters + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the signin-with-apple-style adapter scripts directly (lib.star +// preloaded) over a shared store and virtual clock: the /auth/authorize +// redirect with its single-use code, the /auth/token exchange and refresh +// (client_secret JWTs are minted and verified with REAL ES256 crypto), and +// the /auth/keys JWKS whose served key verifies the minted id_token. +const ( + siwaVMHost = "appleid.apple.test" + siwaVMClientID = "com.example.signin.service" + siwaVMRedirectURI = "https://client.example.test/callback" + siwaVMState = "vm-state-xyz" +) + +// siwaVMPrivPEM mirrors the adapter's documented synthetic EC P-256 keypair +// (README): the public half is served at GET /auth/keys, the private half +// signs client_secret JWTs here exactly the way a real developer key would. +const siwaVMPrivPEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwp+ZPlH6FJFcHfYS +Nd1ENT6RzZQUkTDz67JzlvlvoRShRANCAAREw7SM/k20F3w/oDzR9M6V6jHDK4Hi +RkybQejVvpvgn2EoiMcG6uzUH+aAOgtE+0wCB2gWqc5DoeX6fHyFgDqT +-----END PRIVATE KEY-----` + +type siwaVMFixture struct { + t *testing.T + vc *clock.Clock + vm *starlark.VM + host string +} + +func newSiwaVMFixture(t *testing.T, start time.Time) *siwaVMFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "signin-with-apple-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + src, err := os.ReadFile(filepath.Join(root, "scripts", "oauth.star")) + if err != nil { + t.Fatalf("read oauth.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib oauth.star: %v", err) + } + return &siwaVMFixture{t: t, vc: vc, vm: vm, host: siwaVMHost} +} + +// call drives a handler: query params for the GET routes, form-field map for +// the POSTed /auth/token (what the engine's form parser hands the handler). +func (f *siwaVMFixture) call(handler, method, path string, query, body map[string]string) starlark.Response { + f.t.Helper() + if query == nil { + query = map[string]string{} + } + if body == nil { + body = map[string]string{} + } + anyBody := make(map[string]any, len(body)) + for k, v := range body { + anyBody[k] = v + } + resp, err := f.vm.Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: map[string]string{}, Body: anyBody, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// siwaVMKey parses the documented private key PEM. +func siwaVMKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + block, _ := pem.Decode([]byte(siwaVMPrivPEM)) + if block == nil { + t.Fatal("bad test key PEM") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + t.Fatalf("parse test key: %v", err) + } + priv, ok := key.(*ecdsa.PrivateKey) + if !ok { + t.Fatal("test key is not ECDSA") + } + return priv +} + +// siwaVMSecret mints a REAL ES256 client_secret JWT (raw r||s signature) +// with the documented key: alg ES256, aud appleid.apple.com, exp at +// now+ttl — the exact shape the adapter's verifier demands. +func siwaVMSecret(t *testing.T, priv *ecdsa.PrivateKey, now, ttl int64, aud string) string { + t.Helper() + header := `{"alg":"ES256","kid":"mock-siwa-key-1","typ":"JWT"}` + payload := `{"iss":"MOCKTEAMID","sub":"` + siwaVMClientID + `","aud":"` + aud + + `","iat":` + jsonInt(now) + `,"exp":` + jsonInt(now+ttl) + `}` + return siwaVMSign(t, priv, header, payload) +} + +// siwaVMSign signs a compact-JSON header/payload pair as an ES256 JWT +// (base64url segments, 64-byte raw r||s signature). +func siwaVMSign(t *testing.T, priv *ecdsa.PrivateKey, header, payload string) string { + t.Helper() + h := base64.RawURLEncoding.EncodeToString([]byte(header)) + p := base64.RawURLEncoding.EncodeToString([]byte(payload)) + digest := sha256.Sum256([]byte(h + "." + p)) + r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// jsonInt formats an int64 for hand-built compact JSON payloads. +func jsonInt(n int64) string { + b, _ := json.Marshal(n) + return string(b) +} + +// siwaVMCode runs the authorize redirect once and extracts the code param. +func siwaVMCode(t *testing.T, f *siwaVMFixture, state string) string { + t.Helper() + resp := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{ + "client_id": siwaVMClientID, "redirect_uri": siwaVMRedirectURI, + "state": state, "response_type": "code", "scope": "name email", + }, nil) + if resp.Status != 302 { + t.Fatalf("authorize -> %d: %v", resp.Status, resp.Body) + } + loc := resp.Headers["Location"] + if loc == "" { + t.Fatal("authorize: missing Location header") + } + var code string + for _, kv := range strings.Split(strings.SplitN(loc, "?", 2)[1], "&") { + if strings.HasPrefix(kv, "code=") { + code = strings.TrimPrefix(kv, "code=") + } + } + if code == "" { + t.Fatalf("authorize: no code in Location %q", loc) + } + return code +} + +// siwaVMSegment decodes a base64url JWT segment to a JSON map. +func siwaVMSegment(t *testing.T, token string, idx int) map[string]any { + t.Helper() + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token %q has %d segments, want 3", token, len(parts)) + } + raw, err := base64.RawURLEncoding.DecodeString(parts[idx]) + if err != nil { + t.Fatalf("decode segment %d: %v", idx, err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal segment %d (%s): %v", idx, raw, err) + } + return m +} + +func TestSigninWithAppleVMOAuthFlow(t *testing.T) { + base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + f := newSiwaVMFixture(t, base) + priv := siwaVMKey(t) + secret := siwaVMSecret(t, priv, base.Unix(), 3600, "https://appleid.apple.com") + + // ===== authorize redirects with a single-use code plus state and validates its params ===== + resp := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{ + "client_id": siwaVMClientID, "redirect_uri": siwaVMRedirectURI, + "state": siwaVMState, "response_type": "code", "scope": "name email", + }, nil) + if resp.Status != 302 { + t.Fatalf("authorize -> %d: %v", resp.Status, resp.Body) + } + loc := resp.Headers["Location"] + if !strings.HasPrefix(loc, siwaVMRedirectURI+"?code=") { + t.Fatalf("authorize Location = %q, want %s?code=...", loc, siwaVMRedirectURI) + } + if !strings.Contains(loc, "state="+siwaVMState) { + t.Fatalf("authorize Location %q missing state %q", loc, siwaVMState) + } + // A redirect_uri that already carries a query gets & not ?. + extra := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{ + "client_id": siwaVMClientID, "redirect_uri": "https://client.example.test/cb?x=1", + "response_type": "code", + }, nil) + if l := extra.Headers["Location"]; !strings.Contains(l, "cb?x=1&code=") { + t.Fatalf("authorize Location %q, want & separator after existing query", l) + } + // Missing required params and a wrong response_type are 400s. + if r := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{"response_type": "code"}, nil); r.Status != 400 { + t.Fatalf("authorize without client_id/redirect_uri -> %d, want 400", r.Status) + } + if r := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{ + "client_id": siwaVMClientID, "redirect_uri": siwaVMRedirectURI, "response_type": "token", + }, nil); r.Status != 400 || r.Body["error"] != "unsupported_response_type" { + t.Fatalf("authorize response_type=token -> %d %v, want 400 unsupported_response_type", r.Status, r.Body) + } + + // ===== the token exchange mints a real es256 id_token with apple claim shapes ===== + code := siwaVMCode(t, f, siwaVMState) + tok := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "authorization_code", "code": code, + "client_id": siwaVMClientID, "client_secret": secret, "redirect_uri": siwaVMRedirectURI, + }) + if tok.Status != 200 { + t.Fatalf("token exchange -> %d: %v", tok.Status, tok.Body) + } + if tok.Body["token_type"] != "Bearer" { + t.Fatalf("token_type = %v, want Bearer", tok.Body["token_type"]) + } + if n := ckVMNum(tok.Body["expires_in"]); n != 3600 { + t.Fatalf("expires_in = %v, want 3600", tok.Body["expires_in"]) + } + access, _ := tok.Body["access_token"].(string) + refresh, _ := tok.Body["refresh_token"].(string) + if access == "" || refresh == "" { + t.Fatalf("access/refresh token = %q / %q, want non-empty", access, refresh) + } + idToken, _ := tok.Body["id_token"].(string) + if idToken == "" { + t.Fatalf("id_token = %v, want non-empty", tok.Body["id_token"]) + } + header := siwaVMSegment(t, idToken, 0) + if header["alg"] != "ES256" || header["kid"] != "mock-siwa-key-1" || header["typ"] != "JWT" { + t.Fatalf("id_token JOSE header = %v, want ES256/mock-siwa-key-1/JWT", header) + } + claims := siwaVMSegment(t, idToken, 1) + if claims["iss"] != "https://appleid.apple.com" { + t.Fatalf("id_token iss = %v", claims["iss"]) + } + if claims["aud"] != siwaVMClientID { + t.Fatalf("id_token aud = %v, want %s", claims["aud"], siwaVMClientID) + } + if sub, _ := claims["sub"].(string); !strings.HasPrefix(sub, "00") { + t.Fatalf("id_token sub = %v, want 00-prefixed user id", claims["sub"]) + } + // Apple ships email_verified / is_private_email as STRINGS and + // nonce_supported as a bool — the adapter mirrors the real shapes. + if email, _ := claims["email"].(string); !strings.HasSuffix(email, "@privaterelay.appleid.com") { + t.Fatalf("id_token email = %v, want privaterelay address", claims["email"]) + } + if claims["email_verified"] != "true" || claims["is_private_email"] != "false" { + t.Fatalf("id_token email flags = %v/%v, want \"true\"/\"false\" strings", claims["email_verified"], claims["is_private_email"]) + } + if claims["nonce_supported"] != true { + t.Fatalf("id_token nonce_supported = %v, want boolean true", claims["nonce_supported"]) + } + if iat, exp := ckVMNum(claims["iat"]), ckVMNum(claims["exp"]); exp-iat != 3600 || iat != float64(base.Unix()) { + t.Fatalf("id_token iat/exp = %v/%v, want virtual now and +1h", iat, exp) + } + + // ===== the served jwks verifies the minted id_token signature ===== + keys := f.call("on_get_keys", "GET", "/auth/keys", nil, nil) + if keys.Status != 200 { + t.Fatalf("auth/keys -> %d: %v", keys.Status, keys.Body) + } + keyList, _ := keys.Body["keys"].([]any) + if len(keyList) != 1 { + t.Fatalf("JWKS keys = %d, want 1", len(keyList)) + } + jwk, _ := keyList[0].(map[string]any) + if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["alg"] != "ES256" || jwk["use"] != "sig" || jwk["kid"] != "mock-siwa-key-1" { + t.Fatalf("JWKS key = %v, want EC/P-256/ES256/sig/mock-siwa-key-1", jwk) + } + x, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string)) + if err != nil || len(x) != 32 { + t.Fatalf("JWKS x = %v (%v), want 32 bytes", jwk["x"], err) + } + y, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string)) + if err != nil || len(y) != 32 { + t.Fatalf("JWKS y = %v (%v), want 32 bytes", jwk["y"], err) + } + pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} + segs := strings.Split(idToken, ".") + sig, err := base64.RawURLEncoding.DecodeString(segs[2]) + if err != nil || len(sig) != 64 { + t.Fatalf("id_token signature = %d bytes (%v), want 64 raw r||s", len(sig), err) + } + digest := sha256.Sum256([]byte(segs[0] + "." + segs[1])) + if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) { + t.Fatal("id_token ES256 signature did not verify against the served JWKS key") + } + + // ===== auth codes are single-use and client_secrets are verified cryptographically ===== + replay := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "authorization_code", "code": code, + "client_id": siwaVMClientID, "client_secret": secret, "redirect_uri": siwaVMRedirectURI, + }) + if replay.Status != 400 || replay.Body["error"] != "invalid_grant" { + t.Fatalf("replay auth code -> %d %v, want 400 invalid_grant", replay.Status, replay.Body) + } + // A non-JWT secret, an expired secret, a forged signature and a wrong aud + // are all invalid_client (the secret check precedes the code lookup, so + // the consumed code still exercises it). + for _, tc := range []struct { + name string + secret string + }{ + {"not a jwt", "not-a-jwt"}, + {"expired", siwaVMSecret(t, priv, base.Unix(), -60, "https://appleid.apple.com")}, + {"forged key", siwaVMSecret(t, mustRandomKey(t), base.Unix(), 3600, "https://appleid.apple.com")}, + {"wrong aud", siwaVMSecret(t, priv, base.Unix(), 3600, "https://someone-else.test")}, + } { + r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "authorization_code", "code": code, + "client_id": siwaVMClientID, "client_secret": tc.secret, + }) + if r.Status != 400 || r.Body["error"] != "invalid_client" { + t.Fatalf("%s client_secret -> %d %v, want 400 invalid_client", tc.name, r.Status, r.Body) + } + } + // A code from a different client_id does not exchange. + otherCode := siwaVMCode(t, f, "other") + if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "authorization_code", "code": otherCode, + "client_id": "com.someone.else", "client_secret": secret, + }); r.Status != 400 || r.Body["error"] != "invalid_grant" { + t.Fatalf("client_id mismatch -> %d %v, want 400 invalid_grant", r.Status, r.Body) + } + + // ===== the refresh grant rotates access tokens and rejects stale or foreign inputs ===== + refreshed := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "refresh_token", "refresh_token": refresh, + "client_id": siwaVMClientID, "client_secret": secret, + }) + if refreshed.Status != 200 { + t.Fatalf("refresh -> %d: %v", refreshed.Status, refreshed.Body) + } + newAccess, _ := refreshed.Body["access_token"].(string) + if newAccess == "" || newAccess == access { + t.Fatalf("refresh access_token = %q, want rotated from %q", newAccess, access) + } + if refreshed.Body["token_type"] != "Bearer" || ckVMNum(refreshed.Body["expires_in"]) != 3600 { + t.Fatalf("refresh envelope = %v, want Bearer/3600", refreshed.Body) + } + // An access token is not a refresh token; a foreign client_id is rejected. + if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "refresh_token", "refresh_token": access, + "client_id": siwaVMClientID, "client_secret": secret, + }); r.Status != 400 || r.Body["error"] != "invalid_grant" { + t.Fatalf("refresh with access token -> %d %v, want 400 invalid_grant", r.Status, r.Body) + } + if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "refresh_token", "refresh_token": refresh, + "client_id": "com.someone.else", "client_secret": secret, + }); r.Status != 400 || r.Body["error"] != "invalid_grant" { + t.Fatalf("refresh client_id mismatch -> %d %v, want 400 invalid_grant", r.Status, r.Body) + } + // Unknown grants are refused before anything else. + if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{ + "grant_type": "password", "client_id": siwaVMClientID, "client_secret": secret, + }); r.Status != 400 || r.Body["error"] != "unsupported_grant_type" { + t.Fatalf("grant_type=password -> %d %v, want 400 unsupported_grant_type", r.Status, r.Body) + } +} + +// mustRandomKey generates an unregistered P-256 key (for forged-signature +// cases: well-formed ES256 JWTs that must NOT verify). +func mustRandomKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} diff --git a/adapters/tenderly-style/scripts/lib.star b/adapters/tenderly-style/scripts/lib.star index f5f7ae04..3446c97d 100644 --- a/adapters/tenderly-style/scripts/lib.star +++ b/adapters/tenderly-style/scripts/lib.star @@ -83,6 +83,10 @@ def _build_simulation_result(body, account, project): gas_used = 21000 + (input_len // 2 % 200000) sim_id = _gen_sim_id() + # hash derives from the sim sequence: every simulation must get its own + # transaction hash (real Tenderly hashes the tx contents; passing the + # "sim_NNNNNN" id itself through _to_int collapses every sim to 0x..01) + sim_num = _to_int(sim_id[4:]) # Revert detection: an explicit body flag, or the Error(string) selector # (0x08c379a0) in the calldata prefix — the canonical "revert with reason". @@ -116,7 +120,7 @@ def _build_simulation_result(body, account, project): return { "transaction": { - "hash": "0x" + _hex_pad(_to_int_or_float(sim_id), 64), + "hash": "0x" + _hex_pad(sim_num, 64), "block_number": block_number, "block_hash": "0x" + _hex_pad(_to_int_or_float(block_number) + 100, 64), "status": status, @@ -159,23 +163,27 @@ def _str_to_hex(s): out = out + hexchars[code // 16] + hexchars[code % 16] return out -# _topic_addr formats an address as a 32-byte left-padded topic (64 hex chars). +# _topic_addr formats an address as a 32-byte left-padded topic (64 hex +# chars, lowercase — EVM log topics are raw hex, never checksummed case). def _topic_addr(addr): a = addr if a[:2] == "0x": a = a[2:] + a = a.lower() while len(a) < 64: a = "0" + a return "0x" + a # _abi_error_string ABI-encodes an Error(string) revert output for a reason: -# selector 0x08c379a0 + offset(0x20) + length + data padded to 32 bytes. +# selector 0x08c379a0 + offset(0x20) + length + data padded to whole 32-byte +# words (the ABI always pads to a word boundary, so reasons longer than one +# word need the full next word, not just a minimum of 64 hex chars). def _abi_error_string(reason): selector = "08c379a0" offset = "0000000000000000000000000000000000000000000000000000000000000020" length = _hex_pad(len(reason), 64) data = _str_to_hex(reason) - while len(data) < 64: + while len(data) % 64 != 0: data = data + "0" return "0x" + selector + offset + length + data @@ -218,15 +226,18 @@ def _to_int_or_float(v): return _to_int(v) # _hex_pad converts a number to a zero-padded hex string of given length. +# Zero renders as a real zero ("0"/all-zero padding): EVM hex quantities and +# ABI length words must be exact, so the old coerce-0-to-1 made a 0-value +# trace read "0x1" and an empty string's length word read 1. def _hex_pad(n, length): hexchars = "0123456789abcdef" s = "" v = n - if v == 0: - v = 1 while v > 0: s = hexchars[v % 16] + s v = v // 16 + if s == "": + s = "0" while len(s) < length: s = "0" + s return s diff --git a/adapters/tenderly_style_test.go b/adapters/tenderly_style_test.go new file mode 100644 index 00000000..49417680 --- /dev/null +++ b/adapters/tenderly_style_test.go @@ -0,0 +1,366 @@ +package adapters + +import ( + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the tenderly-style adapter scripts directly (lib.star preloaded) +// over a shared store: the access-key gate against the seeded test token, +// the networks surface and its perPage/page envelope switch, the +// deterministic simulation shapes (plain call, value transfer with the +// ERC-20 Transfer log and balance overrides, ABI-encoded reverts via both +// the explicit flag and the Error(string) selector), and the bundle / +// list / retrieve round-trip. +const tdAuth = "Bearer test-token-tenderly" + +const ( + tdFrom = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" + tdTo = "0xdAC17F958D2ee523a2206206994597C13D831ec7" +) + +// tdERC20Transfer is a 4-byte selector + address + uint256 calldata blob. +const tdERC20Transfer = ("0xa9059cbb00000000000000000000000012345678" + + "90abcdef1234567890abcdef123456780000000000000000000000000000000000000" + + "00000000000000000000f4240") + +type tdFixture struct { + t *testing.T + vms map[string]*starlark.VM +} + +func newTdFixture(t *testing.T, start time.Time) *tdFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "tenderly-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &tdFixture{t: t, vms: map[string]*starlark.VM{ + "sim": load("simulate.star"), "net": load("networks.star"), + }} +} + +func (f *tdFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + resp, err := f.vms[group].Call(handler, starlark.Request{ + Method: method, Path: path, Host: "api.tenderly.test", Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// simulate POSTs one simulation body for the account/project pair. +func (f *tdFixture) simulate(body map[string]any) map[string]any { + f.t.Helper() + r := f.call("sim", "on_simulate", "POST", + "/api/v1/account/vm-suite/project/proj/simulate", + map[string]string{"account": "vm-suite", "project": "proj"}, nil, body, tdAuth) + if r.Status != 200 { + f.t.Fatalf("simulate -> %d: %v", r.Status, r.Body) + } + return r.Body +} + +// tx builds a simulation body for one transaction. +func tdTx(input, value string, extra map[string]any) map[string]any { + body := map[string]any{ + "network_id": "1", + "block_number": 19000000, + "transaction": map[string]any{ + "from": tdFrom, + "to": tdTo, + "gas": 100000, + "gas_price": "1000000000", + "value": value, + "input": input, + }, + } + for k, v := range extra { + body[k] = v + } + return body +} + +func TestTenderlyAuthAndNetworks(t *testing.T) { + f := newTdFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + + // ===== the access-key gate rejects missing and unknown bearers with the slug envelope ===== + // Only the seeded test token validates; everything else is the + // {slug: unauthorized} envelope on every route. + if r := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil, nil, nil, ""); r.Status != 401 || r.Body["slug"] != "unauthorized" { + t.Fatalf("networks without auth -> %d %v, want 401 {slug: unauthorized}", r.Status, r.Body) + } + if r := f.call("sim", "on_simulate", "POST", "/api/v1/account/vm-suite/project/proj/simulate", + map[string]string{"account": "vm-suite", "project": "proj"}, nil, tdTx("0x", "0", nil), "Bearer bogus-token"); r.Status != 401 || r.Body["slug"] != "unauthorized" { + t.Fatalf("simulate with unknown bearer -> %d %v, want 401 {slug: unauthorized}", r.Status, r.Body) + } + + // ===== networks answer the bare array and switch to the paged envelope under perPage ===== + listed := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil, nil, nil, tdAuth) + if listed.Status != 200 || len(listed.BodyList) != 5 { + t.Fatalf("networks -> %d (%d items), want the bare 5-network array", listed.Status, len(listed.BodyList)) + } + first, _ := listed.BodyList[0].(map[string]any) + if first["id"] != "1" || first["name"] != "Ethereum Mainnet" || first["hex_id"] != "0x1" { + t.Fatalf("networks[0] = %v, want the mainnet entry", first) + } + page1 := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil, + map[string]string{"perPage": "2", "page": "1"}, nil, tdAuth) + if page1.Status != 200 { + t.Fatalf("networks page 1 -> %d: %v", page1.Status, page1.Body) + } + if nets, _ := page1.Body["networks"].([]any); len(nets) != 2 { + t.Fatalf("networks page 1 = %d items, want 2 (perPage honored)", len(nets)) + } + if page1.Body["next_page"] != int64(2) && page1.Body["next_page"] != float64(2) { + t.Fatalf("networks page 1 next_page = %v, want 2", page1.Body["next_page"]) + } + page3 := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil, + map[string]string{"perPage": "2", "page": "3"}, nil, tdAuth) + if nets, _ := page3.Body["networks"].([]any); len(nets) != 1 { + t.Fatalf("networks page 3 = %d items, want the last 1", len(nets)) + } + if _, has := page3.Body["next_page"]; has { + t.Fatalf("networks page 3 carries next_page = %v, want none (last page)", page3.Body["next_page"]) + } +} + +func TestTenderlySimulateShapes(t *testing.T) { + f := newTdFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)) + + // ===== a plain simulation round-trips the deterministic Tenderly shape ===== + // gas_used derives from the calldata length; the hash from the sim + // sequence; the zero-value call emits no logs and no overrides. + sim := f.simulate(tdTx(tdERC20Transfer, "0", nil)) + if sim["simulationId"] != "sim_000001" || sim["network"] != "1" { + t.Fatalf("simulationId/network = %v / %v, want sim_000001 / 1", sim["simulationId"], sim["network"]) + } + tx, _ := sim["transaction"].(map[string]any) + if tx["status"] != true { + t.Fatalf("plain call status = %v, want true", tx["status"]) + } + tdNum(t, tx["gas_used"], 21000+float64(len(tdERC20Transfer)/2), "plain call gas_used (21000 + calldata half-length)") + if got, _ := tx["hash"].(string); got != fmt.Sprintf("0x%064x", 1) { + t.Fatalf("plain call hash = %s, want the sequence-derived hash", got) + } + tdNum(t, tx["block_number"], 19000000, "plain call block_number echo") + if got, _ := tx["block_hash"].(string); got != fmt.Sprintf("0x%064x", 19000100) { + t.Fatalf("plain call block_hash = %s, want block_number + 100 padded", got) + } + if tx["input"] != tdERC20Transfer || tx["from"] != tdFrom || tx["to"] != tdTo || tx["value"] != "0" { + t.Fatalf("plain call echo fields = %v / %v / %v / %v", tx["input"], tx["from"], tx["to"], tx["value"]) + } + if tx["output"] != "0x" || tx["revert_reason"] != nil { + t.Fatalf("plain call output/revert_reason = %v / %v, want 0x / null", tx["output"], tx["revert_reason"]) + } + trace, _ := sim["sim_call_trace"].(map[string]any) + if trace["type"] != "CALL" || trace["status"] != true { + t.Fatalf("trace head = %v / %v, want CALL / true", trace["type"], trace["status"]) + } + if trace["gas"] != "0x186a0" || trace["gasUsed"] != fmt.Sprintf("0x%x", 21000+len(tdERC20Transfer)/2) { + t.Fatalf("trace gas fields = %v / %v, want hex quantities for gas 100000 and gas_used", trace["gas"], trace["gasUsed"]) + } + if trace["value"] != "0x0" { + t.Fatalf("trace value = %v, want the exact zero quantity 0x0", trace["value"]) + } + if calls, _ := trace["calls"].([]any); len(calls) != 0 { + t.Fatalf("plain call trace calls = %d, want 0", len(calls)) + } + if logs, _ := sim["logs"].([]any); len(logs) != 0 { + t.Fatalf("plain call logs = %d, want 0 (no value moved)", len(logs)) + } + if bo, _ := sim["balanceOverrides"].(map[string]any); len(bo) != 0 { + t.Fatalf("plain call balanceOverrides = %d entries, want 0", len(bo)) + } + + // ===== a value transfer emits the ERC-20 Transfer log and balance overrides ===== + const oneETH = 1000000000000000000 + xfer := f.simulate(tdTx("0x", "1000000000000000000", nil)) + xtx, _ := xfer["transaction"].(map[string]any) + if xtx["status"] != true { + t.Fatalf("value transfer status = %v, want true", xtx["status"]) + } + logs, _ := xfer["logs"].([]any) + if len(logs) != 1 { + t.Fatalf("value transfer logs = %d, want the single Transfer event", len(logs)) + } + ev, _ := logs[0].(map[string]any) + if ev["address"] != tdTo { + t.Fatalf("Transfer address = %v, want the recipient contract", ev["address"]) + } + topics, _ := ev["topics"].([]any) + if len(topics) != 3 { + t.Fatalf("Transfer topics = %d, want 3", len(topics)) + } + if topics[0] != "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" { + t.Fatalf("Transfer topic0 = %v, want the ERC-20 Transfer signature", topics[0]) + } + if topics[1] != "0x"+strings.Repeat("0", 24)+strings.ToLower(tdFrom[2:]) { + t.Fatalf("Transfer topic1 = %v, want the left-padded sender", topics[1]) + } + if topics[2] != "0x"+strings.Repeat("0", 24)+strings.ToLower(tdTo[2:]) { + t.Fatalf("Transfer topic2 = %v, want the left-padded recipient", topics[2]) + } + if ev["data"] != fmt.Sprintf("0x%064x", oneETH) { + t.Fatalf("Transfer data = %v, want the 32-byte value word", ev["data"]) + } + bo, _ := xfer["balanceOverrides"].(map[string]any) + if bo[tdFrom] != "-1000000000000000000" || bo[tdTo] != "+1000000000000000000" { + t.Fatalf("balanceOverrides = %v, want -/+ the transferred wei on the two ends", bo) + } + + // ===== reverting simulations carry the ABI-encoded Error(string) output ===== + // Both triggers produce status false with the revert reason surfaced on + // the transaction and the trace, and the output is the canonical + // selector + offset + length + word-padded data encoding. + reasons := []string{"insufficient balance", "transfer amount exceeds the allowance by far"} + for i, reason := range reasons { + rev := f.simulate(tdTx("0x", "0", map[string]any{"revert": true, "revert_reason": reason})) + rtx, _ := rev["transaction"].(map[string]any) + if rtx["status"] != false { + t.Fatalf("revert[%d] status = %v, want false", i, rtx["status"]) + } + if rtx["revert_reason"] != reason { + t.Fatalf("revert[%d] revert_reason = %v, want %q", i, rtx["revert_reason"], reason) + } + if got, _ := rtx["output"].(string); got != tdAbiError(reason) { + t.Fatalf("revert[%d] output = %s, want the exact ABI Error(string) encoding %s", i, got, tdAbiError(reason)) + } + rtrace, _ := rev["sim_call_trace"].(map[string]any) + if rtrace["status"] != false || rtrace["error"] != reason { + t.Fatalf("revert[%d] trace = %v / %v, want false / the reason", i, rtrace["status"], rtrace["error"]) + } + if logs, _ := rev["logs"].([]any); len(logs) != 0 { + t.Fatalf("revert[%d] logs = %d, want 0 (no side effects on revert)", i, len(logs)) + } + } + // Selector-detected revert: calldata starting with Error(string)'s + // 0x08c379a0 reverts even without the explicit body flag. + sel := f.simulate(tdTx("0x08c379a0", "0", nil)) + if stx, _ := sel["transaction"].(map[string]any); stx["status"] != false || stx["revert_reason"] != "execution reverted" { + t.Fatalf("selector revert = %v / %v, want false / execution reverted", stx["status"], stx["revert_reason"]) + } + + // ===== bundles fan out per simulation and stored results list and retrieve by id ===== + bundle := f.call("sim", "on_simulate_bundle", "POST", + "/api/v1/account/vm-suite/project/proj/simulate-bundle", + map[string]string{"account": "vm-suite", "project": "proj"}, nil, map[string]any{ + "simulations": []any{tdTx("0x", "0", nil), tdTx("0x", "42", nil)}, + }, tdAuth) + if bundle.Status != 200 { + t.Fatalf("simulate-bundle -> %d: %v", bundle.Status, bundle.Body) + } + results, _ := bundle.Body["simulation_results"].([]any) + if len(results) != 2 { + t.Fatalf("bundle results = %d, want one per simulation", len(results)) + } + firstID, _ := results[0].(map[string]any)["simulationId"].(string) + if bundle.Body["bundle_id"] != "bundle_"+firstID { + t.Fatalf("bundle_id = %v, want bundle_ + the first result's id", bundle.Body["bundle_id"]) + } + if r := f.call("sim", "on_simulate_bundle", "POST", + "/api/v1/account/vm-suite/project/proj/simulate-bundle", + map[string]string{"account": "vm-suite", "project": "proj"}, nil, + map[string]any{"simulations": []any{}}, tdAuth); r.Status != 400 || r.Body["slug"] != "bad_request" { + t.Fatalf("empty bundle -> %d %v, want 400 {slug: bad_request}", r.Status, r.Body) + } + + // List is scoped to the account; retrieve round-trips one stored result. + listed := f.call("sim", "on_list_simulations", "GET", + "/api/v1/account/vm-suite/project/proj/simulations", + map[string]string{"account": "vm-suite", "project": "proj"}, nil, nil, tdAuth) + sims, _ := listed.Body["simulations"].([]any) + if len(sims) != 7 { // 2 reverts + 1 selector + 1 plain + 1 transfer + 2 bundle + t.Fatalf("listed simulations = %d, want the 7 stored", len(sims)) + } + other := f.call("sim", "on_list_simulations", "GET", + "/api/v1/account/other-acct/project/proj/simulations", + map[string]string{"account": "other-acct", "project": "proj"}, nil, nil, tdAuth) + if sims, _ := other.Body["simulations"].([]any); len(sims) != 0 { + t.Fatalf("other account sees %d simulations, want 0", len(sims)) + } + got := f.call("sim", "on_retrieve_simulation", "GET", + "/api/v1/account/vm-suite/project/proj/simulations/"+firstID, + map[string]string{"account": "vm-suite", "project": "proj", "id": firstID}, nil, nil, tdAuth) + if got.Status != 200 || got.Body["simulationId"] != firstID { + t.Fatalf("retrieve %s -> %d %v, want the stored result", firstID, got.Status, got.Body) + } + if r := f.call("sim", "on_retrieve_simulation", "GET", + "/api/v1/account/vm-suite/project/proj/simulations/sim_nope", + map[string]string{"account": "vm-suite", "project": "proj", "id": "sim_nope"}, nil, nil, tdAuth); r.Status != 404 || r.Body["slug"] != "not_found" { + t.Fatalf("retrieve unknown id -> %d %v, want 404 {slug: not_found}", r.Status, r.Body) + } +} + +// tdAbiError ABI-encodes an Error(string) revert output the way Solidity +// does (mirrors lib.star's _abi_error_string): selector, 0x20 offset word, +// length word, data padded to whole 32-byte words. +func tdAbiError(reason string) string { + data := hex.EncodeToString([]byte(reason)) + for len(data)%64 != 0 { + data += "0" + } + return "0x08c379a0" + fmt.Sprintf("%064x", 32) + fmt.Sprintf("%064x", len(reason)) + data +} + +// tdNum compares a JSON number regardless of int64/float64 width (stored +// docs round-trip through the collection store, where ints come back floats). +func tdNum(t *testing.T, v any, want float64, what string) { + t.Helper() + switch n := v.(type) { + case int64: + if float64(n) != want { + t.Fatalf("%s = %d, want %v", what, n, want) + } + case float64: + if n != want { + t.Fatalf("%s = %v, want %v", what, n, want) + } + default: + t.Fatalf("%s is %T(%v), want number %v", what, v, v, want) + } +} diff --git a/adapters/thegraph_style_test.go b/adapters/thegraph_style_test.go new file mode 100644 index 00000000..5e87ee46 --- /dev/null +++ b/adapters/thegraph_style_test.go @@ -0,0 +1,390 @@ +package adapters + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/vektah/gqlparser/v2/ast" + sk "go.starlark.net/starlark" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/graphqlsim" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the thegraph-style adapter scripts directly (lib.star preloaded) +// over a shared store and virtual clock: real GraphQL documents executed by +// the engine's GraphQL executor against the seeded subgraph (collection +// arguments, aliases, where-filter operators, relational joins, _meta), the +// spec-shaped errors[] surface for validation failures and the first cap, +// and the REST SDL endpoint's public/known-key/unknown-key auth triad. +const ( + tgDeploy = "5zvR82QoaXYxfyKOCH8Qfl6p" // Uniswap V3-style deployment (the graphql path) + tgEnsDeploy = "5XqPmWe6gZyrTtFjASCbxgykJ7KbAA8puFezV8vsJoEB" + tgUSDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + tgWETH = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + tgUsdcWeth = "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" + tgWbtcWeth = "0x11b815efb8f581194ae79006d24e0d814b7697f6" +) + +type theGraphFixture struct { + t *testing.T + vc *clock.Clock + vms map[string]*starlark.VM + schema *ast.Schema + host string +} + +func newTheGraphFixture(t *testing.T, start time.Time) *theGraphFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "thegraph-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + sdl, err := os.ReadFile(filepath.Join(root, "schemas", "schema.graphql")) + if err != nil { + t.Fatalf("read schema.graphql: %v", err) + } + schema, err := graphqlsim.LoadSchema(sdl) + if err != nil { + t.Fatalf("load graphql schema: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + load := func(script string) *starlark.VM { + t.Helper() + src, err := os.ReadFile(filepath.Join(root, "scripts", script)) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib %s: %v", script, err) + } + return vm + } + return &theGraphFixture{t: t, vc: vc, schema: schema, host: "api.thegraph.test", vms: map[string]*starlark.VM{ + "gql": load("resolvers.star"), "sdl": load("graphql.star"), + }} +} + +// --- GraphQL harness (mirrors the engine's convention-named dispatch) --- + +// tgResolverSet maps on_ / resolve__ onto the resolvers +// VM, exactly as the engine's graphql transport does. +type tgResolverSet struct{ f *theGraphFixture } + +func (rs *tgResolverSet) Lookup(parentType, field string) (graphqlsim.Resolver, bool) { + fn := "on_" + field + if parentType != "Query" && parentType != "Mutation" { + fn = "resolve_" + parentType + "_" + field + } + vm := rs.f.vms["gql"] + if !vm.Has(fn) { + return nil, false + } + return func(_ context.Context, parent map[string]any, args map[string]any) (any, error) { + callArg, err := starlark.GoToStarlark(map[string]any{"parent": parent, "args": args}) + if err != nil { + return nil, err + } + raw, err := vm.CallRaw(fn, callArg) + if err != nil { + return nil, err + } + return tgResultToGo(raw) + }, true +} + +// tgResultToGo unwraps a respond(...) dict like the engine does; a None body +// is a null GraphQL value. +func tgResultToGo(v sk.Value) (any, error) { + if d, ok := v.(*sk.Dict); ok { + if body, found, _ := d.Get(sk.String("body")); found { + if _, none := body.(sk.NoneType); none { + return nil, nil + } + return starlark.ValueToGo(body) + } + } + if _, none := v.(sk.NoneType); none { + return nil, nil + } + return starlark.ValueToGo(v) +} + +// gql executes a document; callers decide whether an error is expected. +func (f *theGraphFixture) gql(query string, vars map[string]any) (*graphqlsim.Result, error) { + return graphqlsim.Execute(context.Background(), f.schema, query, vars, "", &tgResolverSet{f}, graphqlsim.Options{}) +} + +// gqlOK executes and fatals on validation or execution errors. +func (f *theGraphFixture) gqlOK(query string, vars map[string]any) map[string]any { + f.t.Helper() + res, err := f.gql(query, vars) + if err != nil { + f.t.Fatalf("graphql: %v (query %s)", err, query) + } + if len(res.Errors) > 0 { + f.t.Fatalf("graphql errors: %v (query %s)", res.Errors, query) + } + data, ok := res.Data.(map[string]any) + if !ok { + f.t.Fatalf("data = %v (%T), want object (query %s)", res.Data, res.Data, query) + } + return data +} + +// gqlErrs fatals when the document does NOT produce errors[] and returns +// the joined messages. +func (f *theGraphFixture) gqlErrs(query string, vars map[string]any) (*graphqlsim.Result, string) { + f.t.Helper() + res, err := f.gql(query, vars) + if err != nil { + f.t.Fatalf("graphql: %v (query %s)", err, query) + } + if len(res.Errors) == 0 { + f.t.Fatalf("want errors[], got clean data %v (query %s)", res.Data, query) + } + msgs := make([]string, len(res.Errors)) + for i, e := range res.Errors { + msgs[i] = e.Message + } + return res, strings.Join(msgs, "; ") +} + +// tgObj fetches key as a non-nil object (test-local). +func tgObj(t *testing.T, m map[string]any, key string) map[string]any { + t.Helper() + v, ok := m[key].(map[string]any) + if !ok { + t.Fatalf("%s = %v (%T), want object", key, m[key], m[key]) + } + return v +} + +// tgNum reads a GraphQL Int whether the resolver produced a fresh Starlark +// int or a value that round-tripped the JSON document store. +func tgNum(v any) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + case int: + return int64(n) + } + return -1 +} + +// tgPools runs a pools query and returns the rows. +func (f *theGraphFixture) tgPools(where string) []map[string]any { + f.t.Helper() + q := `{ pools(first: 10` + where + `) { id } }` + data := f.gqlOK(q, nil) + rows, ok := data["pools"].([]any) + if !ok { + f.t.Fatalf("pools = %v, want a list (query %s)", data["pools"], q) + } + out := make([]map[string]any, 0, len(rows)) + for _, r := range rows { + out = append(out, r.(map[string]any)) + } + return out +} + +// TestTheGraphSubgraphQueries: the executor-backed subgraph contract. +func TestTheGraphSubgraphQueries(t *testing.T) { + f := newTheGraphFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC)) + + // ===== pools collection arguments sort by volume and join token0/token1 ===== + data := f.gqlOK(`{ + top: pools(first: 2, orderBy: volumeUSD, orderDirection: desc) { + id token0 { id symbol decimals } token1 { symbol } feeTier volumeUSD + } + }`, nil) + top, ok := data["top"].([]any) + if !ok || len(top) != 2 { + t.Fatalf("pools(first: 2) = %v, want 2 rows", data["top"]) + } + first := top[0].(map[string]any) + if first["id"] != tgUsdcWeth { + t.Fatalf("pools[0].id = %v, want the USDC/WETH pool (highest volumeUSD)", first["id"]) + } + token0 := tgObj(t, first, "token0") + if token0["symbol"] != "USDC" || token0["id"] != tgUSDC { + t.Fatalf("token0 = %v, want the joined USDC entity", token0) + } + if got := tgNum(token0["decimals"]); got != 6 { + t.Fatalf("token0.decimals = %v (%T), want Int 6 (not the stored \"6\" string)", token0["decimals"], token0["decimals"]) + } + if tgObj(t, first, "token1")["symbol"] != "WETH" { + t.Fatalf("token1.symbol = %v, want the joined WETH", first["token1"]) + } + // BigInt/BigDecimal serialize as decimal strings (graph-node wire form). + if first["feeTier"] != "500" || first["volumeUSD"] != "8912345678.901234" { + t.Fatalf("pool scalars = %v/%v, want the decimal-string wire form", first["feeTier"], first["volumeUSD"]) + } + + // ===== where filters map the graph-node suffix operators ===== + // token0_not_in excludes the USDC pools; token0_in and _not keep their + // complements; numeric suffixes compare numerically on decimal strings. + notIn := f.tgPools(`, where: { token0_not_in: ["` + tgUSDC + `"] }`) + if len(notIn) != 1 || notIn[0]["id"] != tgWbtcWeth { + t.Fatalf("token0_not_in=[USDC] pools = %v, want exactly the WBTC pool", notIn) + } + in := f.tgPools(`, where: { token0_in: ["` + tgUSDC + `"] }`) + if len(in) != 2 { + t.Fatalf("token0_in=[USDC] pools = %v, want the two USDC pools", in) + } + notScalar := f.tgPools(`, where: { token0_not: "0x2260fac5e5542a773aa44fbcfedf7c193bc2b5f0" }`) + if len(notScalar) != 2 { + t.Fatalf("token0_not=WBTC pools = %v, want the two USDC pools", notScalar) + } + if got := len(f.tgPools(`, where: { txCount_gt: "700000" } `)); got != 2 { + t.Fatalf("txCount_gt=700000 pools = %d, want 2 (1234567, 890123)", got) + } + // Variables resolve into where clauses like literals do. + data = f.gqlOK(`query($sym: String) { tokens(first: 5, where: {symbol: $sym}) { id symbol } }`, + map[string]any{"sym": "WETH"}) + weth, ok := data["tokens"].([]any) + if !ok || len(weth) != 1 || weth[0].(map[string]any)["symbol"] != "WETH" { + t.Fatalf("where {symbol: $sym} = %v, want the single WETH token", data["tokens"]) + } + // skip paginates past the first page. + data = f.gqlOK(`{ page2: tokens(first: 2, skip: 2) { symbol } }`, nil) + if page2, ok := data["page2"].([]any); !ok || len(page2) != 2 { + t.Fatalf("tokens(first: 2, skip: 2) = %v, want the remaining 2 tokens", data["page2"]) + } + + // ===== validation failures and the first cap surface as GraphQL errors ===== + // Unknown fields/filters are rejected before execution; the graph-node + // first cap fails the field, nulling data through the non-null list. + if _, err := f.gql(`{ pools(first: 1) { id sqrtPrice } }`, nil); err == nil || !strings.Contains(err.Error(), "sqrtPrice") { + t.Fatalf("unknown Pool field error = %v, want it to name sqrtPrice", err) + } + if _, err := f.gql(`{ swaps(first: 5) { id } }`, nil); err == nil || !strings.Contains(err.Error(), "swaps") { + t.Fatalf("unknown root field error = %v, want it to name swaps", err) + } + if _, err := f.gql(`{ pools(first: 1, where: { unknownThing: "x" }) { id } }`, nil); err == nil { + t.Fatal("unknown where filter accepted; want a validation error") + } + if _, err := f.gql(`{ pools(first: 1, orderBy: fakeField) { id } }`, nil); err == nil { + t.Fatal("unknown orderBy enum accepted; want a validation error") + } + res, msgs := f.gqlErrs(`{ pools(first: 2000) { id } }`, nil) + if !strings.Contains(msgs, "first parameter cannot exceed 1000") { + t.Fatalf("first=2000 errors = %q, want the graph-node cap message", msgs) + } + if res.Data != nil { + t.Fatalf("first=2000 data = %v, want null ([Pool!]! failed and propagated)", res.Data) + } + + // ===== domains join owner/resolvedAddress; lookups miss as null ===== + data = f.gqlOK(`{ + domains(first: 10, orderBy: createdAt, orderDirection: asc) { + id name labelName owner { id } resolvedAddress { id } createdAt + } + }`, nil) + domains, ok := data["domains"].([]any) + if !ok || len(domains) != 3 { + t.Fatalf("domains = %v, want the 3 seeded ENS-style domains", data["domains"]) + } + d0 := domains[0].(map[string]any) + if d0["name"] != "vitalik.eth" || d0["labelName"] != "vitalik" { + t.Fatalf("domains[0] = %v, want vitalik.eth first (createdAt asc)", d0) + } + owner := tgObj(t, d0, "owner") + if ownerID, _ := owner["id"].(string); len(ownerID) != 42 || !strings.HasPrefix(ownerID, "0x") { + t.Fatalf("domain.owner.id = %v, want the joined 0x… account", owner["id"]) + } + single := f.gqlOK(`query($id: ID!) { domain(id: $id) { name owner { id } } }`, + map[string]any{"id": d0["id"]}) + if tgObj(t, single, "domain")["name"] != "vitalik.eth" { + t.Fatalf("domain(id) = %v, want the same entity back", single["domain"]) + } + miss := f.gqlOK(`query($id: ID!) { domain(id: $id) { name } }`, map[string]any{"id": "0xmissing"}) + if v, ok := miss["domain"]; !ok || v != nil { + t.Fatalf("domain(0xmissing) = %v, want null without errors[]", miss["domain"]) + } + + // ===== _meta reports the deployment head; Token.pools joins in reverse ===== + data = f.gqlOK(`{ + _meta { deployment network block { number } hasIndexingErrors genesis { number } } + weth: token(id: "`+tgWETH+`") { symbol pools { id } } + }`, nil) + meta := tgObj(t, data, "_meta") + if meta["deployment"] != tgDeploy || meta["network"] != "mainnet" || meta["hasIndexingErrors"] != false { + t.Fatalf("_meta = %v, want the seeded deployment on mainnet without indexing errors", meta) + } + if got := tgNum(tgObj(t, meta, "block")["number"]); got <= 0 { + t.Fatalf("_meta.block.number = %v, want a positive head number", tgObj(t, meta, "block")["number"]) + } + if got := tgNum(tgObj(t, meta, "genesis")["number"]); got != 1 { + t.Fatalf("_meta.genesis.number = %v, want 1", got) + } + wethTok := tgObj(t, data, "weth") + pools, ok := wethTok["pools"].([]any) + if !ok || len(pools) != 2 { + t.Fatalf("WETH pools reverse join = %v, want the 2 pools holding WETH as token1", wethTok["pools"]) + } + + // ===== the REST SDL surface is public and rejects unknown bearer keys ===== + // GET /subgraphs/id/{id}/graphql: anonymous is fine (hosted-service + // semantics); a presented key must be the known one (gateway semantics). + sdlGet := func(auth string) starlark.Response { + t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + r, err := f.vms["sdl"].Call("on_schema", starlark.Request{ + Method: "GET", Path: "/subgraphs/id/" + tgEnsDeploy + "/graphql", Host: f.host, + Headers: headers, Params: map[string]string{"subgraphId": tgEnsDeploy}, Query: map[string]string{}, + }) + if err != nil { + t.Fatalf("on_schema: %v", err) + } + return r + } + anon := sdlGet("") + sdlText, _ := anon.Body["data"].(string) + if anon.Status != 200 || !strings.Contains(sdlText, "type Domain") { + t.Fatalf("anonymous SDL -> %d %q, want the ENS SDL string", anon.Status, sdlText) + } + if ct := anon.Headers["Content-Type"]; ct != "application/graphql" { + t.Fatalf("SDL Content-Type = %q, want application/graphql", ct) + } + bad := sdlGet("Bearer not-a-known-key") + if bad.Status != 401 { + t.Fatalf("unknown bearer SDL -> %d, want 401", bad.Status) + } + if errs, ok := bad.Body["errors"].([]any); !ok || len(errs) != 1 { + t.Fatalf("unknown bearer body = %v, want the GraphQL errors[] envelope", bad.Body) + } else if msg := errs[0].(map[string]any)["message"]; msg != "valid API key expected" { + t.Fatalf("unknown bearer error message = %v, want the gateway phrasing", msg) + } + if known := sdlGet("Bearer mock-graph-api-key"); known.Status != 200 { + t.Fatalf("known bearer SDL -> %d, want 200 (the well-known test key)", known.Status) + } +} diff --git a/adapters/walletconnect-style/adapter.yaml b/adapters/walletconnect-style/adapter.yaml index 9adf529e..c6c1be87 100644 --- a/adapters/walletconnect-style/adapter.yaml +++ b/adapters/walletconnect-style/adapter.yaml @@ -28,9 +28,12 @@ endpoints: handler: scripts/relay.star#on_list_sessions # --- Session actions (parameterized) --- + # concurrency_key: approve and disconnect read-modify-write the session + # doc keyed by topic (find -> update/delete), so same-topic calls serialize. - route: /v1/sessions/{topic}/approve method: POST handler: scripts/relay.star#on_approve_session + concurrency_key: topic - route: /v1/sessions/{topic}/request method: POST handler: scripts/relay.star#on_session_request @@ -40,6 +43,7 @@ endpoints: - route: /v1/sessions/{topic} method: DELETE handler: scripts/relay.star#on_disconnect_session + concurrency_key: topic # Backing stores — collections for stateful data. resources: diff --git a/adapters/walletconnect-style/scripts/lib.star b/adapters/walletconnect-style/scripts/lib.star index 68f129a5..4c3ae4a3 100644 --- a/adapters/walletconnect-style/scripts/lib.star +++ b/adapters/walletconnect-style/scripts/lib.star @@ -96,6 +96,9 @@ def _parse_wc_uri(uri): if at_idx < 0: return None topic = rest[:at_idx] + if topic == "": + # A wc: URI always names a non-empty topic before the @version. + return None after_at = rest[at_idx + 1:] q_idx = _find_char(after_at, "?") if q_idx < 0: diff --git a/adapters/walletconnect-style/scripts/relay.star b/adapters/walletconnect-style/scripts/relay.star index a655245c..970957ec 100644 --- a/adapters/walletconnect-style/scripts/relay.star +++ b/adapters/walletconnect-style/scripts/relay.star @@ -26,6 +26,7 @@ def on_create_pairing(req): body = {} uri = body.get("uri", None) + relay_proto = "irn" if uri != None and uri != "": # Parse the wc: URI to extract the topic and symKey. @@ -34,21 +35,22 @@ def on_create_pairing(req): return respond(400, {"error": "invalid_uri", "message": "could not parse wc: URI"}) topic = parsed["topic"] sym_key = parsed["symKey"] + # The URI names its relay protocol; echo it rather than assume irn. + relay_proto = parsed["relayProtocol"] else: - # No URI — generate a topic from a sequence number. + # No URI — mint a topic AND symKey: a real pairing always carries a + # 256-bit symmetric key (an empty one cannot seed a wc: URI). seq = store_kv_incr("wc", "pairing_seq") topic = _topic("pairing-" + str(seq)) - sym_key = "" + sym_key = _topic("symkey-" + str(seq)) pc = store_collection("pairings") doc = { "topic": topic, - "relay": {"protocol": "irn"}, + "relay": {"protocol": relay_proto}, "expiry": PAIRING_EXPIRY, "state": {"symKey": sym_key}, } - for k in doc: - pass pc.insert(doc) return respond(200, doc) diff --git a/adapters/walletconnect_style_test.go b/adapters/walletconnect_style_test.go new file mode 100644 index 00000000..9f050d46 --- /dev/null +++ b/adapters/walletconnect_style_test.go @@ -0,0 +1,525 @@ +package adapters + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/adapter/runtime" + "stuntapi.com/stunt/internal/primitives" + "stuntapi.com/stunt/internal/primitives/blob" + "stuntapi.com/stunt/internal/primitives/clock" + "stuntapi.com/stunt/internal/primitives/events" + "stuntapi.com/stunt/internal/primitives/kv" + "stuntapi.com/stunt/internal/starlark" +) + +// Drives the walletconnect-style adapter script directly (lib.star preloaded) +// over a shared store and virtual clock: the pairing -> propose -> approve -> +// JSON-RPC request -> extend -> disconnect relay lifecycle, wc: URI parsing, +// the auto-paired symKey, the derived eip155 namespaces, the bare-array +// session list with limit paging, the {error, message} envelopes, and the +// unenforced projectId gate. + +const ( + wcHost = "relay.walletconnect.test" + wcWallet = "0x1234567890abcdef1234567890abcdef12345678" + wcPairingExpiry = 2592000 // 30-day pairing TTL, seconds + wcSessionExpiry = 604800 // 7-day session TTL, seconds +) + +// 64-hex fixtures for the wc: URI round-trip (topics/symKeys carry no 0x). +var ( + wcTopicURI = strings.Repeat("a1b2c3d4", 8) + wcSymKeyURI = strings.Repeat("9f8e7d6c", 8) +) + +func wcBase() time.Time { return time.Unix(1_750_000_000, 0).UTC() } + +type walletconnectFixture struct { + t *testing.T + vc *clock.Clock + vm *starlark.VM + host string +} + +func newWalletconnectFixture(t *testing.T, start time.Time) *walletconnectFixture { + t.Helper() + root := filepath.Join(repoAdaptersDir(t), "walletconnect-style") + libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star")) + if err != nil { + t.Fatalf("read lib.star: %v", err) + } + tmp := t.TempDir() + store, _ := primitives.Open(filepath.Join(tmp, "s.db")) + t.Cleanup(func() { store.Close() }) + kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db")) + t.Cleanup(func() { kvStore.Close() }) + blobStore, _ := blob.Open(filepath.Join(tmp, "blobs")) + t.Cleanup(func() { blobStore.Close() }) + + vc := clock.NewVirtualClock(start) + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(), + }) + src, err := os.ReadFile(filepath.Join(root, "scripts", "relay.star")) + if err != nil { + t.Fatalf("read relay.star: %v", err) + } + vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins) + if err != nil { + t.Fatalf("LoadWithLib relay.star: %v", err) + } + return &walletconnectFixture{t: t, vc: vc, vm: vm, host: wcHost} +} + +func (f *walletconnectFixture) call(handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response { + f.t.Helper() + headers := map[string]string{} + if auth != "" { + headers["Authorization"] = auth + } + if params == nil { + params = map[string]string{} + } + if query == nil { + query = map[string]string{} + } + resp, err := f.vm.Call(handler, starlark.Request{ + Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, Query: query, + }) + if err != nil { + f.t.Fatalf("%s %s: %v", handler, path, err) + } + return resp +} + +// sessions lists the bare-array session index keyed by topic. +func (f *walletconnectFixture) sessions(query map[string]string) map[string]map[string]any { + f.t.Helper() + r := f.call("on_list_sessions", "GET", "/v1/sessions", nil, query, nil, "") + if r.Status != 200 { + f.t.Fatalf("list sessions -> %d: %v", r.Status, r.Body) + } + byTopic := map[string]map[string]any{} + for _, e := range r.BodyList { + m, ok := e.(map[string]any) + if !ok { + f.t.Fatalf("session entry = %T, want an object", e) + } + topic, _ := m["topic"].(string) + if topic == "" { + f.t.Fatalf("session entry has no topic: %v", m) + } + byTopic[topic] = m + } + return byTopic +} + +// wcNum reads a response number as int64 whether the adapter produced a +// Starlark int (computed fresh in this call) or a float (read back through +// the JSON document store) — both marshal to the same JSON number on the wire. +func wcNum(v any) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + } + return 0 +} + +// wcHex reports whether s is exactly n lowercase hex characters. +func wcHex(s string, n int) bool { + if len(s) != n { + return false + } + for _, c := range s { + if !strings.ContainsRune("0123456789abcdef", c) { + return false + } + } + return true +} + +// wcHash reports whether s is a 0x-prefixed 64-hex synthetic hash. +func wcHash(s string) bool { + return strings.HasPrefix(s, "0x") && wcHex(strings.TrimPrefix(s, "0x"), 64) +} + +// TestWalletconnectPairingAndSessionLifecycle: the pairing surface (URI +// round-trip, malformed-URI rejects, auto-generated pairings), session +// proposal + approval with derived eip155 namespaces, and the bare-array +// session list with limit paging. +func TestWalletconnectPairingAndSessionLifecycle(t *testing.T) { + f := newWalletconnectFixture(t, wcBase()) + + // ===== every route answers without a projectId (the gate is not wired) ===== + // The manifest declares identity.token_scheme: bearer and lib.star ships + // _require_project_id, but no handler calls the helper — the relay answers + // with no credential at all. Asserted as-is; see the deviation report. + if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, ""); r.Status != 200 { + t.Fatalf("pairing with no projectId -> %d, want 200 (gate unenforced): %v", r.Status, r.Body) + } + if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "Bearer not-a-project-id"); r.Status != 200 { + t.Fatalf("pairing with a bogus bearer -> %d, want 200 (gate unenforced): %v", r.Status, r.Body) + } + if r := f.call("on_list_sessions", "GET", "/v1/sessions", nil, nil, nil, ""); r.Status != 200 { + t.Fatalf("list with no projectId -> %d, want 200 (gate unenforced)", r.Status) + } + + // ===== a wc: URI pairing round-trips its topic, relay protocol, and symKey ===== + paired := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{ + "uri": "wc:" + wcTopicURI + "@2?relay-protocol=irn&symKey=" + wcSymKeyURI, + }, "") + if paired.Status != 200 { + t.Fatalf("pairing from URI -> %d: %v", paired.Status, paired.Body) + } + if paired.Body["topic"] != wcTopicURI { + t.Fatalf("pairing topic = %v, want the URI topic %s", paired.Body["topic"], wcTopicURI) + } + relay, _ := paired.Body["relay"].(map[string]any) + if relay["protocol"] != "irn" { + t.Fatalf("relay protocol = %v, want irn", relay["protocol"]) + } + if got := wcNum(paired.Body["expiry"]); got != wcPairingExpiry { + t.Fatalf("pairing expiry = %v, want the 30-day TTL %d", paired.Body["expiry"], wcPairingExpiry) + } + state, _ := paired.Body["state"].(map[string]any) + if state["symKey"] != wcSymKeyURI { + t.Fatalf("pairing symKey = %v, want the URI symKey", state["symKey"]) + } + // A URI naming a different relay protocol is echoed, not forced to irn. + other := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{ + "uri": "wc:" + strings.Repeat("0f1e2d3c", 8) + "@2?relay-protocol=custom&symKey=" + strings.Repeat("55667788", 8), + }, "") + otherRelay, _ := other.Body["relay"].(map[string]any) + if other.Status != 200 || otherRelay["protocol"] != "custom" { + t.Fatalf("custom relay-protocol URI -> %d %v, want the echoed protocol", other.Status, other.Body) + } + // Malformed URIs (wrong scheme, missing @version, empty topic) are 400s. + for _, bad := range []string{ + "https://wallet.example.test/uri", // not a wc: URI + "wc:topic-without-a-version", // no @2 + "wc:@2?symKey=abc", // empty topic before @2 + } { + r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{"uri": bad}, "") + if r.Status != 400 { + t.Fatalf("uri %q -> %d, want 400", bad, r.Status) + } + if r.Body["error"] != "invalid_uri" { + t.Fatalf("uri %q error = %v, want invalid_uri", bad, r.Body["error"]) + } + if msg, _ := r.Body["message"].(string); msg == "" { + t.Fatalf("uri %q carries no message: %v", bad, r.Body) + } + } + + // ===== an auto pairing mints a fresh topic and a 64-hex symKey ===== + auto1 := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "") + if auto1.Status != 200 { + t.Fatalf("auto pairing -> %d: %v", auto1.Status, auto1.Body) + } + autoTopic1, _ := auto1.Body["topic"].(string) + autoState1, _ := auto1.Body["state"].(map[string]any) + autoSym1, _ := autoState1["symKey"].(string) + if !wcHex(autoTopic1, 64) || autoTopic1 == wcTopicURI { + t.Fatalf("auto topic = %q, want a fresh 64-hex topic", autoTopic1) + } + if !wcHex(autoSym1, 64) { + t.Fatalf("auto symKey = %q, want a minted 64-hex key (never empty)", autoSym1) + } + auto2 := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "") + autoTopic2, _ := auto2.Body["topic"].(string) + autoState2, _ := auto2.Body["state"].(map[string]any) + if autoTopic2 == autoTopic1 || autoState2["symKey"] == autoSym1 { + t.Fatalf("second auto pairing reuses topic/symKey: %q vs %q", autoTopic2, autoTopic1) + } + + // ===== proposing requires pairingTopic — and accepts one never paired ===== + noTopic := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, map[string]any{}, "") + if noTopic.Status != 400 { + t.Fatalf("propose without pairingTopic -> %d, want 400", noTopic.Status) + } + if noTopic.Body["error"] != "missing_pairingTopic" { + t.Fatalf("propose error = %v, want missing_pairingTopic", noTopic.Body["error"]) + } + // The simulated wallet never consults the pairings store: an unknown + // pairingTopic still proposes. Asserted as-is; see the deviation report. + ghost := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, + map[string]any{"pairingTopic": strings.Repeat("ee", 32)}, "") + if ghost.Status != 200 { + t.Fatalf("propose on an unknown pairingTopic -> %d, want 200 (no pairing check): %v", ghost.Status, ghost.Body) + } + ghostTopic, _ := ghost.Body["topic"].(string) + if !wcHex(ghostTopic, 64) { + t.Fatalf("proposed topic = %q, want 64-hex", ghostTopic) + } + if ghost.Body["acknowledged"] != false || ghost.Body["pairingTopic"] != strings.Repeat("ee", 32) { + t.Fatalf("proposal shape = %v, want acknowledged:false + echoed pairingTopic", ghost.Body) + } + if got := wcNum(ghost.Body["expiry"]); got != wcSessionExpiry { + t.Fatalf("proposal expiry = %v, want the 7-day TTL %d", ghost.Body["expiry"], wcSessionExpiry) + } + if ns, _ := ghost.Body["namespaces"].(map[string]any); len(ns) != 0 { + t.Fatalf("proposal namespaces = %v, want empty until approved", ghost.Body["namespaces"]) + } + proposed := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, map[string]any{ + "pairingTopic": wcTopicURI, + "requiredNamespaces": map[string]any{ + "eip155": map[string]any{ + "chains": []any{"eip155:137", "eip155:10"}, + "methods": []any{"eth_signTypedData_v4"}, + "events": []any{"chainChanged"}, + }, + }, + }, "") + if proposed.Status != 200 { + t.Fatalf("propose -> %d: %v", proposed.Status, proposed.Body) + } + topicA, _ := proposed.Body["topic"].(string) + if topicA == "" || topicA == wcTopicURI { + t.Fatalf("session topic = %q, want its own topic distinct from the pairing", topicA) + } + + // ===== approve acknowledges the session and derives eip155 namespaces ===== + approved := f.call("on_approve_session", "POST", "/v1/sessions/"+topicA+"/approve", + map[string]string{"topic": topicA}, nil, map[string]any{}, "") + if approved.Status != 200 { + t.Fatalf("approve -> %d: %v", approved.Status, approved.Body) + } + if approved.Body["topic"] != topicA || approved.Body["acknowledged"] != true { + t.Fatalf("approval = %v, want acknowledged:true on its topic", approved.Body) + } + ns, _ := approved.Body["namespaces"].(map[string]any) + eip, _ := ns["eip155"].(map[string]any) + accounts, _ := eip["accounts"].([]any) + if len(accounts) != 2 || accounts[0] != "eip155:137:"+wcWallet || accounts[1] != "eip155:10:"+wcWallet { + t.Fatalf("derived accounts = %v, want one per required chain suffixed with the wallet", accounts) + } + if m, _ := eip["methods"].([]any); len(m) != 1 || m[0] != "eth_signTypedData_v4" { + t.Fatalf("derived methods = %v, want the required set", eip["methods"]) + } + if ev, _ := eip["events"].([]any); len(ev) != 1 || ev[0] != "chainChanged" { + t.Fatalf("derived events = %v, want the required set", eip["events"]) + } + // Without requiredNamespaces the wallet falls back to its defaults. + dflt := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, + map[string]any{"pairingTopic": autoTopic1}, "") + if dflt.Status != 200 { + t.Fatalf("propose defaults -> %d: %v", dflt.Status, dflt.Body) + } + dfltTopic, _ := dflt.Body["topic"].(string) + def := f.call("on_approve_session", "POST", "/v1/sessions/"+dfltTopic+"/approve", + map[string]string{"topic": dfltTopic}, nil, map[string]any{}, "") + if def.Status != 200 { + t.Fatalf("approve defaults -> %d: %v", def.Status, def.Body) + } + defNS, _ := def.Body["namespaces"].(map[string]any) + defEip, _ := defNS["eip155"].(map[string]any) + if a, _ := defEip["accounts"].([]any); len(a) != 1 || a[0] != "eip155:1:"+wcWallet { + t.Fatalf("default accounts = %v, want eip155:1:", defEip["accounts"]) + } + if m, _ := defEip["methods"].([]any); len(m) != 2 { + t.Fatalf("default methods = %v, want the 2 wallet defaults", defEip["methods"]) + } + // Unknown topics answer the documented 404 envelope. + unknown := f.call("on_approve_session", "POST", "/v1/sessions/dead00beef/approve", + map[string]string{"topic": "dead00beef"}, nil, map[string]any{}, "") + if unknown.Status != 404 || unknown.Body["error"] != "session_not_found" { + t.Fatalf("approve unknown topic -> %d %v, want 404 session_not_found", unknown.Status, unknown.Body) + } + if msg, _ := unknown.Body["message"].(string); !strings.Contains(msg, "dead00beef") { + t.Fatalf("approve unknown message = %q, want it to name the topic", msg) + } + + // ===== the session list is a bare array capped by limit ===== + all := f.sessions(nil) + if len(all) != 3 { + t.Fatalf("session list has %d entries, want all three proposals", len(all)) + } + a := all[topicA] + if a["acknowledged"] != true { + t.Fatalf("listed approved session = %v, want acknowledged:true", a) + } + if ns, _ := a["namespaces"].(map[string]any); len(ns) == 0 { + t.Fatalf("listed approved session has empty namespaces: %v", a) + } + if got := wcNum(a["expiry"]); got != wcSessionExpiry { + t.Fatalf("listed expiry = %v, want %d", a["expiry"], wcSessionExpiry) + } + if g := all[ghostTopic]; g["acknowledged"] != false { + t.Fatalf("listed unapproved session = %v, want acknowledged:false", g) + } + page1 := f.call("on_list_sessions", "GET", "/v1/sessions", nil, map[string]string{"limit": "1"}, nil, "") + if page1.Status != 200 || len(page1.BodyList) != 1 { + t.Fatalf("limit=1 -> %d (%d entries), want a 1-entry page", page1.Status, len(page1.BodyList)) + } + // The page is a bare array: no envelope object, and no cursor token is + // surfaced anywhere (the offset token must be guessed). Asserted as-is; + // see the deviation report. + if page1.Body != nil { + t.Fatalf("paged list carries an envelope object: %v", page1.Body) + } + t1, _ := page1.BodyList[0].(map[string]any)["topic"].(string) + page2 := f.call("on_list_sessions", "GET", "/v1/sessions", nil, + map[string]string{"limit": "1", "cursor": "1"}, nil, "") + if page2.Status != 200 || len(page2.BodyList) != 1 { + t.Fatalf("cursor=1 -> %d (%d entries), want the other 1-entry page", page2.Status, len(page2.BodyList)) + } + t2, _ := page2.BodyList[0].(map[string]any)["topic"].(string) + known := map[string]bool{topicA: true, ghostTopic: true, dfltTopic: true} + if !known[t1] || !known[t2] || t1 == t2 { + t.Fatalf("pages = %q / %q, want two distinct listed sessions", t1, t2) + } + badCursor := f.call("on_list_sessions", "GET", "/v1/sessions", nil, + map[string]string{"cursor": "not-a-cursor"}, nil, "") + if badCursor.Status != 400 || badCursor.Body["error"] != "invalid_cursor" { + t.Fatalf("invalid cursor -> %d %v, want 400 invalid_cursor", badCursor.Status, badCursor.Body) + } +} + +// TestWalletconnectSessionRequestLifecycle: the auto-approving wallet's +// JSON-RPC surface — the 2.0 envelope with globally monotonic ids, synthetic +// per-method hashes, the missing approval gate, the fixed-TTL extend, and the +// disconnect that retires the topic. +func TestWalletconnectSessionRequestLifecycle(t *testing.T) { + f := newWalletconnectFixture(t, wcBase()) + propose := func(pairingTopic string) string { + r := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, + map[string]any{"pairingTopic": pairingTopic}, "") + if r.Status != 200 { + t.Fatalf("propose -> %d: %v", r.Status, r.Body) + } + topic, _ := r.Body["topic"].(string) + if topic == "" { + t.Fatalf("proposal carries no topic: %v", r.Body) + } + return topic + } + request := func(topic, method string, params []any) starlark.Response { + return f.call("on_session_request", "POST", "/v1/sessions/"+topic+"/request", + map[string]string{"topic": topic}, nil, + map[string]any{"request": map[string]any{"method": method, "params": params}}, "") + } + params := []any{"0x48656c6c6f", wcWallet} + topicA := propose(strings.Repeat("aa", 32)) // paired and approved below + topicB := propose(strings.Repeat("bb", 32)) // never approved + if r := f.call("on_approve_session", "POST", "/v1/sessions/"+topicA+"/approve", + map[string]string{"topic": topicA}, nil, map[string]any{}, ""); r.Status != 200 { + t.Fatalf("approve -> %d: %v", r.Status, r.Body) + } + + // ===== the approval gate is missing: an unacknowledged session still answers ===== + // topicB was never approved; the auto-wallet serves it anyway. Asserted + // as-is; see the deviation report. + if r := request(topicB, "eth_requestAccounts", []any{}); r.Status != 200 { + t.Fatalf("request on an unapproved session -> %d, want 200 (no approval gate): %v", r.Status, r.Body) + } + + // ===== wallet requests answer in a JSON-RPC 2.0 envelope with monotonic ids ===== + // Batch JSON-RPC arrays are not modeled: one request object per call. + id0 := wcNum(request(topicB, "eth_requestAccounts", []any{}).Body["id"]) + acc := request(topicA, "eth_requestAccounts", []any{}) + if acc.Status != 200 { + t.Fatalf("eth_requestAccounts -> %d: %v", acc.Status, acc.Body) + } + if acc.Body["jsonrpc"] != "2.0" || acc.Body["topic"] != topicA { + t.Fatalf("envelope = %v, want jsonrpc 2.0 on its topic", acc.Body) + } + if res, _ := acc.Body["result"].([]any); len(res) != 1 || res[0] != wcWallet { + t.Fatalf("eth_requestAccounts result = %v, want []", acc.Body["result"]) + } + if wcNum(acc.Body["id"]) != id0+1 { + t.Fatalf("eth_requestAccounts id = %v after %d, want the next global id", acc.Body["id"], id0) + } + // The id sequence is global (kv-backed) and increments per call — across + // sessions, not per topic. + accts := request(topicA, "eth_accounts", []any{}) + if wcNum(accts.Body["id"]) != id0+2 || accts.Body["jsonrpc"] != "2.0" { + t.Fatalf("eth_accounts id = %v after %d, want the next global id", accts.Body["id"], id0+2) + } + if res, _ := accts.Body["result"].([]any); len(res) != 1 || res[0] != wcWallet { + t.Fatalf("eth_accounts result = %v, want []", accts.Body["result"]) + } + + // ===== signing and transaction methods return synthetic 0x-hex hashes ===== + for _, method := range []string{"personal_sign", "eth_sendTransaction", "eth_sign"} { + r := request(topicA, method, params) + if r.Status != 200 { + t.Fatalf("%s -> %d: %v", method, r.Status, r.Body) + } + sig, ok := r.Body["result"].(string) + if !ok || !wcHash(sig) { + t.Fatalf("%s result = %v, want a 0x-prefixed 64-hex hash", method, r.Body["result"]) + } + } + // Unknown methods fall through to the same synthetic hash shape. + if r := request(topicA, "eth_signTypedData_v4", params); r.Status != 200 || !wcHash(r.Body["result"].(string)) { + t.Fatalf("unknown method -> %d %v, want the default synthetic hash", r.Status, r.Body) + } + // Hashes are seeded by the request id, so the same method differs per call. + s1, _ := request(topicA, "personal_sign", params).Body["result"].(string) + s2, _ := request(topicA, "personal_sign", params).Body["result"].(string) + if s1 == s2 || !wcHash(s1) || !wcHash(s2) { + t.Fatalf("repeated personal_sign = %q / %q, want distinct id-seeded hashes", s1, s2) + } + // Unknown topics answer the same 404 envelope as the other actions. + if r := request("dead00beef", "eth_requestAccounts", []any{}); r.Status != 404 || r.Body["error"] != "session_not_found" { + t.Fatalf("request unknown topic -> %d %v, want 404 session_not_found", r.Status, r.Body) + } + + // ===== extend echoes the fixed session TTL without persisting anything ===== + ext := f.call("on_extend_session", "POST", "/v1/sessions/"+topicA+"/extend", + map[string]string{"topic": topicA}, nil, map[string]any{}, "") + if ext.Status != 200 || ext.Body["topic"] != topicA { + t.Fatalf("extend -> %d %v, want the echoed topic", ext.Status, ext.Body) + } + if got := wcNum(ext.Body["expiry"]); got != wcSessionExpiry { + t.Fatalf("extend expiry = %v, want the 7-day TTL %d", ext.Body["expiry"], wcSessionExpiry) + } + // The stored doc is untouched — and the value is a TTL constant, not the + // absolute unix timestamp real WC expiry uses. Asserted as-is; see the + // deviation report. + if got := wcNum(f.sessions(nil)[topicA]["expiry"]); got != wcSessionExpiry { + t.Fatalf("stored expiry after extend = %v, want the unchanged %d", got, wcSessionExpiry) + } + if r := f.call("on_extend_session", "POST", "/v1/sessions/dead00beef/extend", + map[string]string{"topic": "dead00beef"}, nil, map[string]any{}, ""); r.Status != 404 { + t.Fatalf("extend unknown topic -> %d, want 404", r.Status) + } + + // ===== disconnect retires the topic and every later call 404s ===== + del := f.call("on_disconnect_session", "DELETE", "/v1/sessions/"+topicA, + map[string]string{"topic": topicA}, nil, map[string]any{}, "") + if del.Status != 200 || del.Body["acknowledged"] != false { + t.Fatalf("disconnect -> %d %v, want acknowledged:false", del.Status, del.Body) + } + if msg, _ := del.Body["message"].(string); msg != "session disconnected" { + t.Fatalf("disconnect message = %q, want session disconnected", msg) + } + remaining := f.sessions(nil) + if _, gone := remaining[topicA]; gone { + t.Fatalf("session %s still listed after disconnect", topicA) + } + if _, kept := remaining[topicB]; !kept { + t.Fatalf("disconnect also removed the unrelated session %s", topicB) + } + for _, c := range []struct{ handler, method, suffix string }{ + {"on_approve_session", "POST", "/approve"}, + {"on_session_request", "POST", "/request"}, + {"on_extend_session", "POST", "/extend"}, + {"on_disconnect_session", "DELETE", ""}, + } { + r := f.call(c.handler, c.method, "/v1/sessions/"+topicA+c.suffix, + map[string]string{"topic": topicA}, nil, map[string]any{}, "") + if r.Status != 404 || r.Body["error"] != "session_not_found" { + t.Fatalf("%s after disconnect -> %d %v, want 404 session_not_found", c.handler, r.Status, r.Body) + } + if msg, _ := r.Body["message"].(string); !strings.Contains(msg, topicA) { + t.Fatalf("%s after disconnect message = %q, want it to name the topic", c.handler, msg) + } + } +} diff --git a/conformance/matrix.json b/conformance/matrix.json index 68a7b9c0..864f90a8 100644 --- a/conformance/matrix.json +++ b/conformance/matrix.json @@ -5,8 +5,8 @@ "tiers": { "sdk_and_vm": 2, "sdk_only": 34, - "vm_only": 38, - "boot": 24 + "vm_only": 59, + "boot": 3 } }, "adapters": [ @@ -724,9 +724,16 @@ "api_name": "Apple Push Notification service (APNs)", "api_version": "v2", "routes": 2, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the provider token gate distinguishes missing invalid and expired tokens", + "a push to the known device returns 200 with a canonical uuid apns-id", + "unknown device tokens are 400 baddevicetoken", + "empty aps payloads are 400 payloadempty", + "sent notifications are retrievable per device" + ], "missing": [ "No apns-topic, apns-push-type, apns-priority, or apns-collapse-id handling", "No provider-certificate auth — only JWT provider tokens", @@ -735,7 +742,8 @@ ], "deviations": [ "Provider JWT verified against one fixed P-256 key whose private half is published", - "GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)" + "GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)", + "sent_at on the notifications endpoint is a constant; 410 Unregistered is modeled but unreachable" ], "covered": [ { @@ -8978,9 +8986,30 @@ "api_name": "Avalara AvaTax REST API", "api_version": "2", "routes": 8, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "every v2 endpoint demands a credential: a bare call is a 401 AuthenticationRequired envelope", + "any Bearer or any HTTP Basic credential opens the gate", + "a non-Basic/Non-Bearer scheme does not count as a credential", + "the effective rate keys off the address state (CA 0.095) with a State/County/City/Special breakdown", + "the summary aggregates the taxable base per jurisdiction", + "per-line tax rounds to cents: two lines aggregate, line 2 keeps its own tax", + "SDK decimal strings (\"100.00\") price identically to JSON numbers", + "the shipFrom/shipTo form keys off shipTo (NY 0.0875)", + "unknown or missing addresses fall back to the synthetic 0.0825 default", + "create prices the document, mints id/code/companyId and applies AvaTax defaults", + "an omitted date defaults to the clock's today, and advances with it", + "read round-trips by id; unknown ids are 404 NotFound", + "the list supports OData $filter and $orderBy with @recordsetCount", + "$top/$skip pages through an @odata.nextLink that round-trips", + "void flips status to Cancelled and the record reads back cancelled", + "re-void is idempotent", + "the companies catalog lists DEFAULT and STORE1 with default locations", + "nexus $filter literals are typed: id eq 1001 matches ints, hasNexus eq true matches bools", + "the taxcode catalog is filterable by taxCode" + ], "missing": [ "No company CRUD — GET /v2/companies list only", "No address validation (/v2/addresses/resolve)", @@ -8989,7 +9018,9 @@ "No jurisdiction lookups — definitions serve nexuses and taxcodes only" ], "deviations": [ - "Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate" + "Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate", + "Void returns a minimal {id, status} envelope; real AvaTax returns the full TransactionModel", + "Re-void is idempotent 200; real AvaTax rejects voiding a Cancelled document" ], "covered": [ { @@ -26867,9 +26898,17 @@ "api_name": "CloudKit Web Services API", "api_version": "1", "routes": 5, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the s2s signature gate rejects unsigned tampered stale and foreign-key requests", + "users current returns the s2s owner identity", + "zones list seeds defaults filters by prefix and pages", + "records lookup returns the seeded shape with inline NOT_FOUND", + "records query filters sorts and pages on a numeric resultsLimit", + "records modify creates updates and deletes round-trip" + ], "missing": [ "No private or shared database surfaces (/database/1/.../private|shared)", "No assets: no /assets/upload endpoint or asset field handling", @@ -28760,9 +28799,28 @@ "api_name": "Dropbox API", "api_version": "2", "routes": 8, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "upload takes the JSON {path, content} convenience body (documented deviation)", + "the real RPC upload (Dropbox-API-Arg header + raw octet-stream body) lands identically", + "re-uploading an existing path answers the real mode:\"add\" conflict", + "mode overwrite replaces in place; autorename forks a suffixed path", + "list_folder returns the whole path-prefix subtree, not one level", + "the root listing spans everything; unknown and file paths carry distinct 409 tags", + "paging slices the filtered subtree by body cursor, ignoring query strings", + "download streams raw bytes by path and by id, with metadata alongside", + "folders and unknown paths decline under the 409 path envelope", + "get_temporary_link pairs the file's metadata with the synthetic link", + "a presented bearer must be registered: unknown and expired tokens get distinct 401 tags", + "an absent Authorization header stays open (documented deviation)", + "create_folder mints folder metadata and conflicts case-insensitively", + "get_current_account returns the synthetic /2/users snapshot", + "deleting a folder removes its entire subtree from every read path", + "trash tombstones audit the exact cascade batch", + "delete is permanent: a re-created path is a brand-new entry" + ], "missing": [ "No move or copy endpoints (/2/files/move_v2, /2/files/copy_v2)", "No upload sessions for large files (/2/files/upload_session/*)", @@ -29173,9 +29231,18 @@ "api_name": "Dune Analytics API", "api_version": "v1", "routes": 6, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the api-key gate rejects missing, empty and non-bearer authorization", + "an execution walks PENDING -\u003e EXECUTING -\u003e COMPLETED as the clock advances", + "a missing required parameter is the 400 envelope and both SDK parameter shapes resolve", + "simulate_fail terminates QUERY_STATE_FAILED and results carry the failure envelope", + "the inline-result route completes synchronously", + "results pages honor limit/offset with a followable next_uri", + "the CSV variant streams text/csv for the same page" + ], "missing": [ "No query management: GET/POST/PATCH/DELETE /api/v1/query endpoints", "No execution cancel (POST /api/v1/execution/{id}/cancel)", @@ -29186,7 +29253,8 @@ "deviations": [ "executions follow a fixed clock: EXECUTING at +1s, COMPLETED at +3s after execute", "queries come from a static 3-entry catalog; no real SQL is executed", - "simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger" + "simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger", + "Auth is a presence-only Bearer; real Dune uses the x-dune-api-key header" ], "covered": [ { @@ -100006,9 +100074,18 @@ "api_name": "ERC-4337 Bundler RPC", "api_version": "0.7", "routes": 2, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "supportedEntryPoints, chainId, and the JSON-RPC envelope", + "estimateUserOperationGas validates the full v0.7 field set", + "sendUserOperation answers a deterministic hash and defaults the entry point", + "the op walks mempool -\u003e bundled -\u003e included on the virtual clock", + "simulate_fail reverts on inclusion with the AA95 reason", + "the paymaster signs the op into paymasterAndData", + "missing or invalid userOps are 400s" + ], "missing": [ "No eth_chainId, eth_blockNumber, or eth_getBlockByNumber passthrough", "No ERC-7677 paymaster RPC (pm_* methods); only local /paymaster/sign", @@ -100020,7 +100097,9 @@ "gas estimates are deterministic fixed values; real bundlers differ per implementation", "inclusion runs on a fixed clock: mempool 0-1s, bundled 1-3s, included at \u003e=3s", "eth_sendUserOperation accepts {simulate_fail:true} as a third params element", - "mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures" + "mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures", + "userOp keeps the v0.6 paymasterAndData field though the adapter is EntryPoint-v0.7-only", + "Error envelopes use generic -32602 rather than AA-prefixed codes (-32500...)" ], "covered": [ { @@ -100291,16 +100370,25 @@ "api_name": "Etherscan API", "api_version": "1.0", "routes": 1, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the apikey query parameter gates every module call", + "unknown modules and actions answer the NOTOK envelope over HTTP 200", + "balance reads the seeded ledger; unknown addresses default to \"0\"", + "txlist scopes by address then applies block filters, sort and paging", + "contract verification: ABI, source, and the unverified fallback", + "stats and token holders keep every number a decimal string" + ], "missing": [ - "No txlistinternal, tokentx, or getLogs actions", + "No getLogs action (txlistinternal/tokentx are served, hard-coded empty)", "No logs, proxy, or gastracker modules (gasoracle, eth_call passthrough)", "No getminedblocks, getblocknobytime, or nodecount actions" ], "deviations": [ - "Auth accepts any non-empty apikey; only a missing key yields the error envelope" + "Auth accepts any non-empty apikey; only a missing key yields the error envelope", + "txlistinternal/tokentx/tokenbalance return hard-coded empty results" ], "covered": [ { @@ -115679,9 +115767,28 @@ "api_name": "Jumio API", "api_version": "v1", "routes": 5, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "a missing, bare or wrong-scheme token is a 401 in the Jumio error envelope", + "a scan create answers PENDING with a synthetic decimal scan reference", + "sequential creates advance the reference sequence", + "a create without merchantScanReference is a 400", + "PENDING holds through the processing window then flips to DONE", + "a FAILED scan carries a real reject reason and its description", + "unknown scans are 404 on every parameterized route", + "extracted data is None while the scan is PENDING", + "DONE exposes the synthetic document extraction", + "FAILED scans answer data with a 409 repeating the reason", + "delete removes the scan and later reads are 404s", + "deleting after the terminal window still advances the lifecycle", + "a correctly MACed webhook body is accepted", + "a tampered body, wrong MAC or missing header is a 401", + "the terminal transition emits exactly one signed scan.completed", + "failed scans emit scan.failed carrying the rejection reason", + "a delete-driven terminal transition also emits, then nothing more" + ], "missing": [ "No document or selfie image upload/retrieval endpoints", "No hosted redirect verification flow; API scan creation only", @@ -115691,7 +115798,9 @@ "Scan lifecycle on a fixed clock: PENDING ~3s then DONE or FAILED at +3s", "simulate_fail and simulate_reject_reason create fields are stunt-only hooks", "Webhook HMAC secret is the public constant stunt_jumio_mock_signing_key", - "POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint" + "POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint", + "Bearer-presence gate; real Jumio uses HTTP Basic against a server-token store", + "Scan references are decimal groups, not UUIDs; extracted PII is fixed synthetic" ], "covered": [ { @@ -115779,9 +115888,54 @@ "api_name": "LinkedIn API", "api_version": "v2", "routes": 8, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "authorize without redirect_uri, state or client_id is invalid_request", + "authorize redirects back with a fresh code and the state echoed", + "a redirect_uri that already carries a query is joined with \u0026", + "the exchange demands grant_type=authorization_code", + "an unknown code is 400 invalid_grant", + "a good exchange mints a 60-day token pair for a fresh member", + "the code is single-use: a replay is invalid_grant", + "client mismatches are 400 invalid_client", + "a mismatched attempt must not burn the code", + "a second flow mints a distinct member", + "the refresh grant demands client creds", + "an unknown refresh token is invalid_grant", + "refresh rotates the pair and keeps the member", + "the presented refresh token is single-use", + "rotation chains: the new refresh token refreshes again", + "a missing bearer is 401 in the service error envelope", + "wrong schemes and unknown bearers answer the same 401", + "every API route enforces the same gate", + "a bearer dies at its clock-derived 60-day expiry", + "userinfo returns the OAuth member profile", + "publishing as anyone but the token's member is a 403", + "a good publish mints a ugcPost urn echoed in x-linkedin-id", + "the post resolves to a share urn carrying its own author", + "resolving an unknown urn is a 404", + "unconfigured, publishing is unthrottled", + "arming fail_after injects 429 REQUEST_LIMIT_EXCEEDED", + "the limit is per member", + "a throttled attempt creates no post", + "q must be author", + "reply resolves urn:li:person:me to the authenticated member", + "commenting as anyone but the caller is a 403", + "replying to an unknown object is a 404", + "ingest lists only the token member's comments", + "member B's comment resolved me and lists only under B", + "createdOn is clock-stamped and monotonic", + "count pages with a next link that round-trips the query", + "without count the whole list returns unpaged", + "a malformed start cursor is a 400", + "an unknown entity is a 404", + "each queryType totals base+3/5/7/11 split across two daily buckets", + "entity accepts both the parenthesized and bare urn forms", + "start past the data returns an empty page", + "an unknown queryType falls back to the base total (deviation, as-is)" + ], "missing": [ "No ugcPosts delete or edit (DELETE /v2/ugcPosts/{id})", "No organizations or company page API (GET /v2/organizations)", @@ -115789,7 +115943,9 @@ "No video upload flow or multi-image carousel posts" ], "deviations": [ - "Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook" + "Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook", + "An unknown analytics queryType silently falls back to the base total; real LinkedIn 400s", + "Refresh tokens never expire; only access tokens carry the 60-day expiry" ], "covered": [ { @@ -188352,16 +188508,35 @@ "api_name": "1inch Aggregation Protocol API", "api_version": "v6.0", "routes": 5, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "a quote returns token pairs, a decimal toAmount and a 100-point split", + "quotes are deterministic and address matching is case-insensitive", + "the toAmount scales linearly with the input amount", + "a same-token quote scales the amount by the pseudo-rate (as-is)", + "missing params and unknown tokens are 400 error envelopes", + "a swap returns router-addressed calldata with gas and gasPrice", + "the swap toAmount matches the quote for the same input", + "slippage is optional and ignored (as-is)", + "missing params and unknown tokens are 400s", + "the spender is the router contract address", + "approve calldata targets the token with the max allowance", + "a missing or unknown token is a 400", + "the token list is an address-keyed map of six tokens", + "every token in the list is quotable as a source" + ], "missing": [ "No Fusion+ order flow (order quotes, submission, status, events)", "No Limit Order Protocol endpoints (create, list, history)", "No raw transaction broadcast, status check, or chains list endpoints" ], "deviations": [ - "Quotes deterministic from src/dst/amount; same input yields same toAmount and split" + "Quotes deterministic from src/dst/amount; same input yields same toAmount and split", + "Quote field is toAmount; real v6.0 returns dstAmount", + "Same-token quotes return amount x pseudo-rate; real API rejects", + "slippage is optional and ignored on swap; real v6.0 requires it" ], "covered": [ { @@ -188427,9 +188602,18 @@ "api_name": "Onfido API", "api_version": "v3.6", "routes": 7, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "a missing or non-Token Authorization header is 401 authorization_error", + "applicant create flags exactly the blank names and reads back by id", + "document and live photo uploads bind to a real applicant and default side", + "check create demands report_names and a known applicant", + "the check completes from the clock and emits check.completed exactly once", + "simulate_fail completes with consider and consider breakdowns", + "the webhook receiver MACs the exact raw bytes" + ], "missing": [ "No SDK token generation (POST /v3.6/sdk_token)", "No applicant list, update, delete, or resume endpoints", @@ -188441,7 +188625,9 @@ "Check lifecycle fixed: in_progress ~3s then complete; awaiting_applicant skipped", "simulate_fail yields result consider; real sandbox uses special sandbox documents", "Webhook HMAC secret is the public constant stunt_onfido_mock_signing_key", - "POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint" + "POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint", + "Synthetic sequential ids (app-000001...) where real Onfido uses UUIDs", + "The awaiting_applicant phase is skipped; documents assumed on file" ], "covered": [ { @@ -188857,9 +189043,20 @@ "api_name": "OpenSea API", "api_version": "2.0.0", "routes": 7, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the X-API-KEY gate 401s every surface with the V1ErrorWrapper envelope", + "the asset list seeds five mock-punks NFTs and filters by collection_slug", + "single-asset reads match the address case-insensitively and 404 unknown shapes", + "collections read back contracts and string-typed stats; unknown slugs 404", + "limit/next cursor pagination walks the pages and 400s a malformed cursor", + "events filter by collection_slug and event_type", + "listings carry the Seaport ask shape: the NFT in offer, payment in consideration", + "offers invert the shape: payment in offer, the NFT in consideration", + "created offers are stateful, deterministic, and defaulted" + ], "missing": [ "No collections list endpoint (GET /api/v2/collections), only get-by-slug", "No listing creation or order cancellation (offer creation only)", @@ -188867,7 +189064,10 @@ "No NFT transfer history endpoint (chain/{chain}/transfers)" ], "deviations": [ - "X-API-KEY accepted as any non-empty value; no real key validation" + "X-API-KEY accepted as any non-empty value; no real key validation", + "Asset routes serve the deprecated v2 surface; real v2 replaced them with collection-scoped nfts endpoints", + "Identical offers re-create and store duplicate orders (no order_hash dedupe)", + "Create-offer body and response are simulator-specific, not the documented criteria-offer shapes" ], "covered": [ { @@ -189870,9 +190070,29 @@ "api_name": "Persona Inquiry API", "api_version": "2023-01-05", "routes": 5, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "a missing, bare or wrong-scheme token is a 401 in the JSON:API error envelope", + "a create mints a zero-padded inq_ id in the JSON:API envelope", + "sequential creates advance the id sequence", + "any Bearer is accepted: the gate checks presence, not a store (as-is)", + "a create missing template_id or reference_id is a 400 invalid_request", + "the status derives from the clock created to pending to completed", + "resume restarts the clock at pending without duplicating verifications", + "simulate_fail declines at the terminal transition and seeds nothing", + "unknown inquiries are JSON:API 404s on every parameterized route", + "verifications are empty until the terminal transition fires", + "completion seeds the government-id and selfie verifications", + "a fresh correctly-signed webhook is accepted", + "a tampered body or wrong MAC is a 401 invalid_signature", + "a stale or far-future t is a 401 invalid_timestamp", + "a missing header or unparseable signature is a 401", + "polling through pending still emits exactly one inquiry.completed", + "re-reads and post-resume re-completions do not re-emit", + "a declined inquiry emits inquiry.declined signed the same way" + ], "missing": [ "No inquiries list endpoint (GET /api/inquiry/v1/inquiries)", "No inquiry templates or reports endpoints", @@ -189884,7 +190104,9 @@ "Fixed clock lifecycle: created (0-1s), pending (1-3s), completed or declined (+3s)", "simulate_fail create flag yields declined instead of completed (stunt-only)", "Webhook HMAC secret is the public constant stunt_persona_mock_signing_key", - "POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window" + "POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window", + "JSON:API attributes are snake_case; real Persona serializes kebab-case", + "Create takes a flat body; real API expects the JSON:API data/attributes wrapper" ], "covered": [ { @@ -191062,16 +191284,32 @@ "api_name": "Pinata API", "api_version": "1.0", "routes": 6, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "missing or half-present credentials are 401 with the error envelope", + "the API key pair and a Bearer JWT both open testAuthentication", + "pinJSONToIPFS pins content to a real CIDv0", + "re-pinning identical content is isDuplicate, not a new pin", + "pinFileToIPFS sizes and names the pin from the multipart parts", + "pinList filters by hash, size, status, and metadata name", + "pinStart/pinEnd bound the date-pinned window", + "pinByHash requires hash and matches the CID exactly", + "pinList pages at the real default of 10 rows", + "unpin removes the CID; a second unpin is 403 FORBIDDEN" + ], "missing": [ "No pin-by-CID endpoint (POST /pinning/addHashToPinQueue)", "No pin-jobs listing (GET /pinning/pinJobs)", "No metadata update or pin-policy change (POST /pinning/hashMetadata)", "No pinned-data-usage endpoint (GET /data/userPinnedDataTotal)" ], - "deviations": [], + "deviations": [ + "Credentials are presence-checked only; any non-empty key pair or Bearer JWT passes", + "Every stored pin is status pinned — unpinned/pending/failed not modeled (no job queue)", + "JSON pin CIDs derive from stunt canonical serialization, not byte-identical to real Pinata" + ], "covered": [ { "method": "POST", @@ -191103,8 +191341,8 @@ "method": "POST", "route": "/pinning/pinFileToIPFS", "tags": [ - "body", - "stateful" + "stateful", + "clock" ] }, { @@ -191112,7 +191350,8 @@ "route": "/pinning/pinJSONToIPFS", "tags": [ "body", - "stateful" + "stateful", + "clock" ] }, { @@ -194094,9 +194333,19 @@ "api_name": "Reddit API", "api_version": "1.0", "routes": 2, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "a missing or generic User-Agent is 429 on both routes", + "the token endpoint requires HTTP Basic client credentials", + "a permanent authorization_code mints access and refresh together", + "a refresh grant returns a fresh access token and no new refresh", + "submit requires a bearer the adapter itself minted", + "a valid submit returns the t3_ thing envelope", + "missing sr or title stay HTTP 200 with Reddit error triples", + "an access token dies after its one-hour window" + ], "missing": [ "No listing endpoints (subreddit hot/new/top, thread comment pages)", "No votes or comments (/api/vote, /api/comment)", @@ -194105,7 +194354,9 @@ "No subreddit, search, or inbox/messages endpoints" ], "deviations": [ - "User-Agent gate simplified: any UA containing / and ( passes, else 429" + "User-Agent gate simplified: any UA containing / and ( passes, else 429", + "The authorization_code grant never validates code or redirect_uri — any or missing code mints tokens", + "Post ids are plain sequence strings, not Reddit base36" ], "covered": [ { @@ -194351,9 +194602,19 @@ "api_name": "RevenueCat API", "api_version": "v1", "routes": 7, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "a missing or unknown key is a 401 {code, message} envelope", + "the public pk_ SDK key passes subscriber reads and receipt posts but is 401 on restricted writes", + "GET subscriber is get-or-create and answers in the v1 CustomerInfo envelope", + "receipt validation mirrors the real 400 order: app_user_id, platform, fetch_token, bad token", + "an ios receipt grants the pro entitlement with real trial math, and renewals stack", + "a google-play dict receipt feeds the product and lands in non_subscriptions", + "revoke lapses a live subscription; delete and the 404 envelopes", + "expiry is derived on read: a lapsed trial drops its entitlement" + ], "missing": [ "No attribution endpoint (POST /v1/subscribers/{id}/attribution)", "No offers API (promotional offer creation)", @@ -194362,10 +194623,11 @@ "No v2 REST surface (customers, subscriptions, entitlements)" ], "deviations": [ - "Webhook registration endpoint is simulator-only; real RC v1 webhooks are dashboard-configured", "fetch_token prefixed with invalid is the deterministic bad-receipt 400 path", "Subscription expiry is derive-on-read; first read past it fires EXPIRATION", - "POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests" + "POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests", + "pk_ public keys are simulator convention; real RC public keys are appl_/goog_-prefixed", + "REST webhook registration does not exist in real RC v1 (dashboard-configured)" ], "covered": [ { @@ -194984,9 +195246,18 @@ "api_name": "Twilio SendGrid v3 API", "api_version": "v3", "routes": 5, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the bearer gate rejects missing and unknown keys with SendGrid's grant envelope", + "mail send answers 202 with an empty body, an X-Message-Id, and flattened personalizations", + "the retrieval endpoint pages with limit and the opaque offset cursor", + "the delivery lifecycle derives processed -\u003e delivered (or dropped) on read, exactly once", + "the Email Activity query language narrows the list", + "event webhook settings round-trip and require a URL when enabled", + "deliveries are ECDSA P-256 signed over timestamp + raw body and fire once per recipient stage" + ], "missing": [ "No single-message fetch (GET /v3/messages/{msg_id})", "No GET of event-webhook settings (POST-only)", @@ -195000,8 +195271,9 @@ "Webhook ECDSA signature is raw r||s (64 bytes), not Twilio ASN.1 DER encoding", "simulate_fail: true in send body forces dropped terminal (simulator extension)", "delivered derives at fixed +3s on first list read, not real async delivery", - "Webhook deliveries are single stunt-enveloped objects; real SendGrid POSTs a JSON array", - "Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields" + "Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields", + "Each delivery wraps one event in the transport envelope; real SendGrid batches a JSON array of events", + "asm, sandbox_mode, and batch_id are accepted but not modeled" ], "covered": [ { @@ -195155,6 +195427,7 @@ "body", "auth", "stateful", + "errors", "clock" ] }, @@ -196230,9 +196503,16 @@ "api_name": "Sign in with Apple", "api_version": "v2", "routes": 3, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "authorize redirects with a single-use code plus state and validates its params", + "the token exchange mints a real es256 id_token with apple claim shapes", + "the served jwks verifies the minted id_token signature", + "auth codes are single-use and client_secrets are verified cryptographically", + "the refresh grant rotates access tokens and rejects stale or foreign inputs" + ], "missing": [ "No token revocation (POST /auth/revoke)", "No user endpoint (GET /auth/user for name/email)", @@ -203705,9 +203985,17 @@ "api_name": "Tenderly Simulation API", "api_version": "v1", "routes": 5, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "the access-key gate rejects missing and unknown bearers with the slug envelope", + "networks answer the bare array and switch to the paged envelope under perPage", + "a plain simulation round-trips the deterministic Tenderly shape", + "a value transfer emits the ERC-20 Transfer log and balance overrides", + "reverting simulations carry the ABI-encoded Error(string) output", + "bundles fan out per simulation and stored results list and retrieve by id" + ], "missing": [ "No contracts endpoints (import/verify/update contracts per project)", "No alerts or notification-rules API", @@ -203715,7 +204003,9 @@ ], "deviations": [ "gas_used is derived from input length, not real EVM execution; status defaults to true", - "An explicit revert:true body flag forces the revert path; real API has no such switch" + "An explicit revert:true body flag forces the revert path; real API has no such switch", + "Bearer auth where real Tenderly uses the X-Access-Key header", + "Responses mix camelCase where Tenderly is snake_case and nests simulation.id" ], "covered": [ { @@ -203803,16 +204093,25 @@ "api_version": "1.0", "routes": 1, "graphql": true, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "pools collection arguments sort by volume and join token0/token1", + "where filters map the graph-node suffix operators", + "validation failures and the first cap surface as GraphQL errors", + "domains join owner/resolvedAddress; lookups miss as null", + "_meta reports the deployment head; Token.pools joins in reverse", + "the REST SDL surface is public and rejects unknown bearer keys" + ], "missing": [ "No subgraph deployment/management surface; the two seeded ids are fixed", "No indexing-status queries beyond _meta (no /status endpoint)", "No GraphQL subscriptions over websockets; HTTP queries only" ], "deviations": [ - "One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment" + "One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment", + "_meta.block.number advances per query; the real head is block-height driven" ], "covered": [ { @@ -205793,9 +206092,22 @@ "api_name": "WalletConnect (Relay Protocol v2)", "api_version": "2.0", "routes": 7, - "verification": "boot", + "verification": "VM", "sdks": [], "behaviors": [], + "vm_behaviors": [ + "every route answers without a projectId (the gate is not wired)", + "a wc: URI pairing round-trips its topic, relay protocol, and symKey", + "an auto pairing mints a fresh topic and a 64-hex symKey", + "proposing requires pairingTopic — and accepts one never paired", + "approve acknowledges the session and derives eip155 namespaces", + "the session list is a bare array capped by limit", + "the approval gate is missing: an unacknowledged session still answers", + "wallet requests answer in a JSON-RPC 2.0 envelope with monotonic ids", + "signing and transaction methods return synthetic 0x-hex hashes", + "extend echoes the fixed session TTL without persisting anything", + "disconnect retires the topic and every later call 404s" + ], "missing": [ "No WebSocket relay; the real IRN protocol is WSS JSON-RPC pub/sub, not HTTP REST", "No session update, ping, or event emission (chainChanged/accountsChanged)", @@ -205803,7 +206115,10 @@ ], "deviations": [ "Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)", - "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed" + "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed", + "The projectId gate ships but is never wired — every route answers without a credential", + "Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing", + "Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)" ], "covered": [ { diff --git a/conformance/matrix.yaml b/conformance/matrix.yaml index b5036757..15e23896 100644 --- a/conformance/matrix.yaml +++ b/conformance/matrix.yaml @@ -49,6 +49,7 @@ adapters: deviations: - "Provider JWT verified against one fixed P-256 key whose private half is published" - "GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)" + - "sent_at on the notifications endpoint is a constant; 410 Unregistered is modeled but unreachable" missing: - "No apns-topic, apns-push-type, apns-priority, or apns-collapse-id handling" - "No provider-certificate auth — only JWT provider tokens" @@ -117,6 +118,8 @@ adapters: avalara-style: deviations: - "Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate" + - "Void returns a minimal {id, status} envelope; real AvaTax returns the full TransactionModel" + - "Re-void is idempotent 200; real AvaTax rejects voiding a Cancelled document" missing: - "No company CRUD — GET /v2/companies list only" - "No address validation (/v2/addresses/resolve)" @@ -345,6 +348,7 @@ adapters: - "executions follow a fixed clock: EXECUTING at +1s, COMPLETED at +3s after execute" - "queries come from a static 3-entry catalog; no real SQL is executed" - "simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger" + - "Auth is a presence-only Bearer; real Dune uses the x-dune-api-key header" missing: - "No query management: GET/POST/PATCH/DELETE /api/v1/query endpoints" - "No execution cancel (POST /api/v1/execution/{id}/cancel)" @@ -398,6 +402,8 @@ adapters: - "inclusion runs on a fixed clock: mempool 0-1s, bundled 1-3s, included at >=3s" - "eth_sendUserOperation accepts {simulate_fail:true} as a third params element" - "mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures" + - "userOp keeps the v0.6 paymasterAndData field though the adapter is EntryPoint-v0.7-only" + - "Error envelopes use generic -32602 rather than AA-prefixed codes (-32500...)" missing: - "No eth_chainId, eth_blockNumber, or eth_getBlockByNumber passthrough" - "No ERC-7677 paymaster RPC (pm_* methods); only local /paymaster/sign" @@ -429,8 +435,9 @@ adapters: etherscan-style: deviations: - "Auth accepts any non-empty apikey; only a missing key yields the error envelope" + - "txlistinternal/tokentx/tokenbalance return hard-coded empty results" missing: - - "No txlistinternal, tokentx, or getLogs actions" + - "No getLogs action (txlistinternal/tokentx are served, hard-coded empty)" - "No logs, proxy, or gastracker modules (gasoracle, eth_call passthrough)" - "No getminedblocks, getblocknobytime, or nodecount actions" fattureincloud-style: @@ -631,6 +638,8 @@ adapters: - "simulate_fail and simulate_reject_reason create fields are stunt-only hooks" - "Webhook HMAC secret is the public constant stunt_jumio_mock_signing_key" - "POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint" + - "Bearer-presence gate; real Jumio uses HTTP Basic against a server-token store" + - "Scan references are decimal groups, not UUIDs; extracted PII is fixed synthetic" missing: - "No document or selfie image upload/retrieval endpoints" - "No hosted redirect verification flow; API scan creation only" @@ -638,6 +647,8 @@ adapters: linkedin-style: deviations: - "Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook" + - "An unknown analytics queryType silently falls back to the base total; real LinkedIn 400s" + - "Refresh tokens never expire; only access tokens carry the 60-day expiry" missing: - "No ugcPosts delete or edit (DELETE /v2/ugcPosts/{id})" - "No organizations or company page API (GET /v2/organizations)" @@ -694,6 +705,9 @@ adapters: oneinch-style: deviations: - "Quotes deterministic from src/dst/amount; same input yields same toAmount and split" + - "Quote field is toAmount; real v6.0 returns dstAmount" + - "Same-token quotes return amount x pseudo-rate; real API rejects" + - "slippage is optional and ignored on swap; real v6.0 requires it" missing: - "No Fusion+ order flow (order quotes, submission, status, events)" - "No Limit Order Protocol endpoints (create, list, history)" @@ -704,6 +718,8 @@ adapters: - "simulate_fail yields result consider; real sandbox uses special sandbox documents" - "Webhook HMAC secret is the public constant stunt_onfido_mock_signing_key" - "POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint" + - "Synthetic sequential ids (app-000001...) where real Onfido uses UUIDs" + - "The awaiting_applicant phase is skipped; documents assumed on file" missing: - "No SDK token generation (POST /v3.6/sdk_token)" - "No applicant list, update, delete, or resume endpoints" @@ -713,6 +729,9 @@ adapters: opensea-style: deviations: - "X-API-KEY accepted as any non-empty value; no real key validation" + - "Asset routes serve the deprecated v2 surface; real v2 replaced them with collection-scoped nfts endpoints" + - "Identical offers re-create and store duplicate orders (no order_hash dedupe)" + - "Create-offer body and response are simulator-specific, not the documented criteria-offer shapes" missing: - "No collections list endpoint (GET /api/v2/collections), only get-by-slug" - "No listing creation or order cancellation (offer creation only)" @@ -741,6 +760,8 @@ adapters: - "simulate_fail create flag yields declined instead of completed (stunt-only)" - "Webhook HMAC secret is the public constant stunt_persona_mock_signing_key" - "POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window" + - "JSON:API attributes are snake_case; real Persona serializes kebab-case" + - "Create takes a flat body; real API expects the JSON:API data/attributes wrapper" missing: - "No inquiries list endpoint (GET /api/inquiry/v1/inquiries)" - "No inquiry templates or reports endpoints" @@ -761,7 +782,10 @@ adapters: - "No shared albums (list, join, leave, unshare)" - "No favorites marking or favorite filtering in search" pinata-style: - deviations: [] + deviations: + - "Credentials are presence-checked only; any non-empty key pair or Bearer JWT passes" + - "Every stored pin is status pinned — unpinned/pending/failed not modeled (no job queue)" + - "JSON pin CIDs derive from stunt canonical serialization, not byte-identical to real Pinata" missing: - "No pin-by-CID endpoint (POST /pinning/addHashToPinQueue)" - "No pin-jobs listing (GET /pinning/pinJobs)" @@ -854,6 +878,8 @@ adapters: reddit-style: deviations: - "User-Agent gate simplified: any UA containing / and ( passes, else 429" + - "The authorization_code grant never validates code or redirect_uri — any or missing code mints tokens" + - "Post ids are plain sequence strings, not Reddit base36" missing: - "No listing endpoints (subreddit hot/new/top, thread comment pages)" - "No votes or comments (/api/vote, /api/comment)" @@ -874,10 +900,11 @@ adapters: - "No broadcasts or tags" revenuecat-style: deviations: - - "Webhook registration endpoint is simulator-only; real RC v1 webhooks are dashboard-configured" - "fetch_token prefixed with invalid is the deterministic bad-receipt 400 path" - "Subscription expiry is derive-on-read; first read past it fires EXPIRATION" - "POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests" + - "pk_ public keys are simulator convention; real RC public keys are appl_/goog_-prefixed" + - "REST webhook registration does not exist in real RC v1 (dashboard-configured)" missing: - "No attribution endpoint (POST /v1/subscribers/{id}/attribution)" - "No offers API (promotional offer creation)" @@ -903,8 +930,9 @@ adapters: - "Webhook ECDSA signature is raw r||s (64 bytes), not Twilio ASN.1 DER encoding" - "simulate_fail: true in send body forces dropped terminal (simulator extension)" - "delivered derives at fixed +3s on first list read, not real async delivery" - - "Webhook deliveries are single stunt-enveloped objects; real SendGrid POSTs a JSON array" - "Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields" + - "Each delivery wraps one event in the transport envelope; real SendGrid batches a JSON array of events" + - "asm, sandbox_mode, and batch_id are accepted but not modeled" missing: - "No single-message fetch (GET /v3/messages/{msg_id})" - "No GET of event-webhook settings (POST-only)" @@ -1008,6 +1036,8 @@ adapters: deviations: - "gas_used is derived from input length, not real EVM execution; status defaults to true" - "An explicit revert:true body flag forces the revert path; real API has no such switch" + - "Bearer auth where real Tenderly uses the X-Access-Key header" + - "Responses mix camelCase where Tenderly is snake_case and nests simulation.id" missing: - "No contracts endpoints (import/verify/update contracts per project)" - "No alerts or notification-rules API" @@ -1015,6 +1045,7 @@ adapters: thegraph-style: deviations: - "One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment" + - "_meta.block.number advances per query; the real head is block-height driven" missing: - "No subgraph deployment/management surface; the two seeded ids are fixed" - "No indexing-status queries beyond _meta (no /status endpoint)" @@ -1062,6 +1093,9 @@ adapters: deviations: - "Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)" - "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed" + - "The projectId gate ships but is never wired — every route answers without a credential" + - "Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing" + - "Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)" missing: - "No WebSocket relay; the real IRN protocol is WSS JSON-RPC pub/sub, not HTTP REST" - "No session update, ping, or event emission (chainChanged/accountsChanged)" diff --git a/internal/engine/sendgrid_style_test.go b/internal/engine/sendgrid_style_test.go index 1d15188f..987f6b61 100644 --- a/internal/engine/sendgrid_style_test.go +++ b/internal/engine/sendgrid_style_test.go @@ -272,5 +272,6 @@ func sgPostJSON(t *testing.T, rawurl, auth string, body map[string]any) *http.Re if err != nil { t.Fatal(err) } + defer resp.Body.Close() return resp } diff --git a/internal/engine/signin_with_apple_style_test.go b/internal/engine/signin_with_apple_style_test.go index aa03dfcb..05cfd9b5 100644 --- a/internal/engine/signin_with_apple_style_test.go +++ b/internal/engine/signin_with_apple_style_test.go @@ -400,6 +400,7 @@ func TestSignInWithAppleStyleAdapter(t *testing.T) { respMal := siwaGetNoRedirect(t, base+"/auth/authorize?"+ url.Values{"client_id": {"com.test"}, "redirect_uri": {"http://localhost/cb"}, "response_type": {"code"}, "scope": {"email"}, "state": {"s-mal"}}.Encode()) + defer respMal.Body.Close() codeMal := siwaExtractParam(respMal.Header.Get("Location"), "code") malBody, malStatus := siwaPostForm(t, base+"/auth/token", url.Values{ "grant_type": {"authorization_code"},