diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dafd759..766211f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ All notable changes to **stunt** are documented here. The format is based on ## [Unreleased] +## [0.48.0] — 2026-08-17 + +### Testing + +- **SDK conformance wave 2 — three more official/standard SDKs, 17 new + checks (36 across six SDK families).** + - **twilio-go** — message create, the `queued → sent → delivered` + lifecycle driven by SDK Fetch polling, the `+15005550001` magic + invalid-number → `failed` trigger, list filters, and status-callback + webhooks verified against Twilio's documented HMAC-SHA1 formula. + - **go-shopify** (bold-commerce, the standard Go client) — webhook + registration, order creates, `page_info` cursor pagination through + the SDK's `NextPageOptions` Link-header walking, and deliveries + verified by the SDK's own `VerifyWebhookRequest` HMAC validator. + - **google-api-go-client / x/oauth2** — the full authorization-code + exchange, refresh with rotation, userinfo, and the marquee: + **`idtoken.Validate` — Google's own RS256+JWKS verifier — accepting + the adapter-minted id_token against the adapter-served + `/oauth2/v3/certs`.** + +### Adapters + +- Wave-2 findings, all fixed: + - **twilio-style's mock auth token contained underscores** — real + Twilio tokens are 32-char alphanumeric, and official SDKs validate + that client-side, rejecting the credentials before any request. The + documented token is now `feed0000face1111beef2222cafe3333` (update + any hardcoded credential). + - **twilio-style versioned its API `/2010-06-01/`** — the real API + (and every SDK) uses `/2010-04-01/`. All routes renamed. + - **twilio-style status callbacks delivered the stunt envelope, not + Twilio's callback shape.** Real Twilio POSTs the message resource as + form parameters signed with + `base64(HMAC-SHA1(token, url + sorted key/value pairs))`; the + adapter now delivers exactly that (via `events_emit_raw`), so real + receivers — and Twilio's documented validation — verify out of the + box. The lifecycle engine test now verifies every callback's + signature. + - **shopify-style rendered webhook ids, embedded customer ids, + fulfillment/transaction ids, and variant ids as JSON strings** — + Shopify ids are numeric; typed SDKs (`go-shopify`) reject the + response outright. The id coercion is total over the shapes an id + can take (stored string, JSON int, JSON float) — the first cut + crashed on numeric customer ids, 500ing the most common Shopify + create pattern and poisoning later order lists (caught in review, + pinned by the embedded-customer conformance check). + - **google-style's token endpoint rejected HTTP Basic client + credentials** (RFC 6749 §2.3.1) — the default style of + `golang.org/x/oauth2` and the Google SDKs. The first attempt also + burned the single-use code, so the library's retry could never + succeed. Both grant types now accept Basic or form credentials. + ## [0.47.0] — 2026-08-17 ### Testing diff --git a/README.md b/README.md index 11ae30d7..4b847d29 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,10 @@ stunt catalog search stripe # browse the adapter registry Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial, synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Every one passes an adversarial input-safety sweep (garbage params, null/malformed bodies, ~30 tampered -cursor/limit param names — never a 5xx) plus coverage-guided fuzzing of the engine's parsers -and dispatch (`just fuzz` for longer rounds). Highlights: +cursor/limit param names — never a 5xx), coverage-guided fuzzing of the engine's parsers and +dispatch (`just fuzz`), and conformance suites that drive **real provider SDKs** — stripe-go, +aws-sdk-go-v2, go-github, twilio-go, go-shopify, google-api-go-client — end-to-end against +the adapters (`just conformance`). Highlights: | Adapter | Simulates | Backing | |---|---|---| diff --git a/adapters/README.md b/adapters/README.md index 419941d3..bca424a2 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -320,7 +320,7 @@ events_emit("push", payload, {"X-Hub-Signature-256": "sha256=" + sig, "X-GitHub- | shopify-style | yes | `X-Shopify-Hmac-SHA256` | `shpss_stunt_mock_api_client_secret` | base64 | | whatsapp-style | yes | `X-Hub-Signature-256` (Meta) | `whatsapp_stunt_mock_app_secret_2026` | hex | | square-style | yes | `X-Square-HmacSha256-Signature` (URL+body) | `sq0sip_stunt_mock_signature_key_2026` | base64 | -| twilio-style | yes | `X-Twilio-Signature` (SHA-1, URL+body) | `twilio_auth_token` | base64 | +| twilio-style | yes | `X-Twilio-Signature` (SHA-1, URL+sorted form params) | `feed0000face1111beef2222cafe3333` | base64 | | discord-style | yes | `X-Signature-Ed25519` + `X-Signature-Timestamp` (Ed25519 over ts+body) | Ed25519 keypair in adapter (`_ED25519_PUBLIC_KEY`); verify deliveries/interactions against it | hex | | adyen-style | deferred | — | — | — | | braintree-style | yes | body/header `bt_signature` (`public_key\|hex(HMAC-SHA1(private_key, bt_payload))`) + `bt-hash` | `stunt_mock_public_key_2026` / `stunt_mock_private_key_2026` (SHA-1 over the base64 `bt_payload`, not the outer JSON body) | hex | @@ -328,7 +328,7 @@ events_emit("push", payload, {"X-Hub-Signature-256": "sha256=" + sig, "X-GitHub- Unsigned-by-design emitters (real provider has no receiver-computable HMAC): paypal-style (cert-based signature verified via the `POST /v1/notifications/verify-webhook-signature` API, which the adapter also serves) and revenuecat-style (v1 webhooks are unsigned; validate the `app_user_id` via `GET /v1/subscribers/{id}`). -Deferred providers need schemes the current primitives don't cover yet: Adyen signs an in-body `hmacSignature` over a derived field-concatenation. (Twilio was deferred for HMAC-SHA-1 over the sink URL + body — now shipped, via `crypto.hmac_sha1` + the new `events_target()` builtin for the delivery URL.) Square likewise MACs the notification URL + body. Braintree was deferred for raw-byte HMAC keys + form-encoded delivery — now shipped, simplified to the SHA-1-over-base64-payload MAC delivered as JSON body + headers. +Deferred providers need schemes the current primitives don't cover yet: Adyen signs an in-body `hmacSignature` over a derived field-concatenation. (Twilio was deferred for HMAC-SHA-1 over the sink URL + the callback's sorted form params — now shipped, via `crypto.hmac_sha1` + the new `events_target()` builtin for the delivery URL.) Square likewise MACs the notification URL + body. Braintree was deferred for raw-byte HMAC keys + form-encoded delivery — now shipped, simplified to the SHA-1-over-base64-payload MAC delivered as JSON body + headers. To receive events, set `config.webhook_url` in your `stunt.yaml`: diff --git a/adapters/google-style/scripts/oauth.star b/adapters/google-style/scripts/oauth.star index 7d9a725c..c82dae0a 100644 --- a/adapters/google-style/scripts/oauth.star +++ b/adapters/google-style/scripts/oauth.star @@ -107,11 +107,18 @@ def on_token(req): body = {} grant_type = body.get("grant_type", "") + # OAuth2 (RFC 6749 §2.3.1) client credentials may arrive as HTTP Basic + # — the DEFAULT style of golang.org/x/oauth2 and most Google SDKs. + basic_cid, basic_secret = _basic_client(req) + if grant_type == "refresh_token": presented = body.get("refresh_token", "") client_id = body.get("client_id") or "" client_secret = body.get("client_secret") or "" + if client_id == "" or client_secret == "": + client_id = basic_cid + client_secret = basic_secret if client_id == "" or client_secret == "": return respond(400, {"error": "invalid_client", "error_description": "missing client creds"}) @@ -135,6 +142,9 @@ def on_token(req): client_id = body.get("client_id", "") client_secret = body.get("client_secret", "") redirect_uri = body.get("redirect_uri", "") + if client_id == "" or client_secret == "": + client_id = basic_cid + client_secret = basic_secret cc = store_collection("codes") code_doc = cc.get(code) @@ -151,6 +161,19 @@ def on_token(req): scope = code_doc.get("scope", "openid email profile") return respond(200, _issue_tokens(_mint_user(), scope, client_id)) +# _basic_client extracts RFC 6749 §2.3.1 HTTP Basic client credentials. +def _basic_client(req): + h = req["headers"].get("Authorization", "") + if h == None or h[:6] != "Basic ": + return "", "" + dec = crypto.base64_decode(h[6:]) + if dec == None: + return "", "" + i = dec.find(":") + if i < 0: + return "", "" + return dec[:i], dec[i + 1:] + # on_certs serves the JWKS at Google's real discovery path # (/oauth2/v3/certs). The key is REAL: derived from the fixed synthetic # RSA keypair whose private half signs the id_tokens minted when the diff --git a/adapters/shopify-style/scripts/lib.star b/adapters/shopify-style/scripts/lib.star index f8600a4a..3ec381a2 100644 --- a/adapters/shopify-style/scripts/lib.star +++ b/adapters/shopify-style/scripts/lib.star @@ -144,10 +144,30 @@ def _next_id(kind): n = store_kv_incr("shopify", kind + "_seq") return str(_BASE_ID + n) -# _num_id converts a stored string id back to an int for JSON responses -# (Shopify returns numeric ids in REST/GraphQL responses). -def _num_id(s): - return _to_int(s) +# _num_id converts a stored or inbound id to an int for JSON responses +# (Shopify returns numeric ids in REST/GraphQL responses). TOTAL over the +# shapes an id can take: stored string, JSON int, JSON float (the engine +# decodes numbers to float when a client sends an id as a number) — a +# plain string parser raised on the latter two and 500'd the response. +def _num_id(v): + if v == None: + return 0 + if type(v) == "int": + return v + if type(v) == "float": + return int(v) + return _to_int(str(v)) + +# _customer_id_numeric coerces an embedded customer object's id (typed SDKs +# unmarshal order.customer.id as int64). +def _customer_id_numeric(c): + if c == None: + return None + if c.get("id", None) == None: + return c + out = dict(c) + out["id"] = _num_id(c["id"]) + return out # _seed populates default products, orders, and customers on first access so # that list endpoints return realistic data without prior setup. @@ -365,7 +385,7 @@ def _order_view(o): "total_price": o.get("total_price", "0.00"), "currency": o.get("currency", "USD"), "line_items": line_views, - "customer": o.get("customer", {}), + "customer": _customer_id_numeric(o.get("customer", {})), "order_number": o.get("order_number", 0), "name": o.get("name", ""), "closed_at": o.get("closed_at", None), @@ -387,9 +407,22 @@ def _product_view(p): "tags": p.get("tags", ""), "created_at": p.get("created_at", _now()), "updated_at": p.get("updated_at", _now()), - "variants": p.get("variants", []), + "variants": _variants_numeric(p.get("variants", [])), } +# _variants_numeric coerces variant id/product_id to numeric at render — +# seeded variants store string ids and typed SDKs unmarshal them as ints. +def _variants_numeric(vs): + out = [] + for v in vs: + w = dict(v) + if w.get("id", None) != None: + w["id"] = _num_id(w["id"]) + if w.get("product_id", None) != None: + w["product_id"] = _num_id(w["product_id"]) + out.append(w) + return out + def _customer_view(c): return { diff --git a/adapters/shopify-style/scripts/orders.star b/adapters/shopify-style/scripts/orders.star index f7f51c22..4b09207c 100644 --- a/adapters/shopify-style/scripts/orders.star +++ b/adapters/shopify-style/scripts/orders.star @@ -104,6 +104,11 @@ def on_create_order(req): customer = input_ord.get("customer", {}) if customer == None: customer = {} + else: + # Shopify embeds numeric customer ids on orders. + if customer.get("id", None) != None: + customer = dict(customer) + customer["id"] = _num_id(customer["id"]) email = input_ord.get("email", "") if email == None: email = "" @@ -301,9 +306,9 @@ def on_create_fulfillment(req): oc.update(oid, order) # Emit webhook event if subscribed. - _emit_if_subscribed("fulfillments/create", fulfillment) + _emit_if_subscribed("fulfillments/create", _fulfillment_view(fulfillment)) - return respond(201, {"fulfillment": fulfillment}) + return respond(201, {"fulfillment": _fulfillment_view(fulfillment)}) # on_create_transaction records a transaction (capture/sale/refund/void) # against the order and re-derives the order's financial_status from ALL its @@ -350,10 +355,33 @@ def on_create_transaction(req): order["updated_at"] = _now() oc.update(oid, order) - return respond(201, {"transaction": transaction}) + return respond(201, {"transaction": _transaction_view(transaction)}) # --- helpers --- +# _fulfillment_view / _transaction_view render numeric ids (stored as +# strings; typed SDKs unmarshal Shopify ids as ints). +def _fulfillment_view(f): + out = dict(f) + out["id"] = _num_id(f["id"]) + out["order_id"] = _num_id(f["order_id"]) + if out.get("line_items", None) != None: + lines = [] + for li in out["line_items"]: + w = dict(li) + if w.get("id", None) != None: + w["id"] = _num_id(w["id"]) + lines.append(w) + out["line_items"] = lines + return out + +def _transaction_view(t): + out = dict(t) + out["id"] = _num_id(t["id"]) + out["order_id"] = _num_id(t["order_id"]) + return out + + # _order_view returns the public-facing order object. Internal keys (the # per-line _fulfilled counters) are projected away by _line_item_view. # Numeric ids are converted from stored strings back to ints. diff --git a/adapters/shopify-style/scripts/webhooks.star b/adapters/shopify-style/scripts/webhooks.star index 07e5e5b8..4fc834a4 100644 --- a/adapters/shopify-style/scripts/webhooks.star +++ b/adapters/shopify-style/scripts/webhooks.star @@ -95,7 +95,7 @@ def on_delete_webhook(req): # _webhook_view returns the public-facing webhook subscription object. def _webhook_view(w): return { - "id": w["id"], + "id": _num_id(w["id"]), "topic": w.get("topic", ""), "address": w.get("address", ""), "format": w.get("format", "json"), diff --git a/adapters/twilio-style/README.md b/adapters/twilio-style/README.md index 94a52ae9..2f945128 100644 --- a/adapters/twilio-style/README.md +++ b/adapters/twilio-style/README.md @@ -1,6 +1,6 @@ # Twilio-style adapter -A stunt adapter for simulating a **Twilio REST API (2010-06-01)** locally. +A stunt adapter for simulating a **Twilio REST API (2010-04-01)** locally. All data is synthetic — no real API data is included. > **Unofficial / not affiliated.** This adapter is not affiliated with, endorsed @@ -14,14 +14,14 @@ A faithful behavioral mock of Twilio's Programmable Messaging, Voice, and Verify surfaces, designed for local integration testing without a real Twilio account: -- **Send SMS/MMS:** `POST /2010-06-01/Accounts/{Sid}/Messages.json` (`{To, From, Body}`). -- **List messages:** `GET /2010-06-01/Accounts/{Sid}/Messages.json` (cursor-paginated +- **Send SMS/MMS:** `POST /2010-04-01/Accounts/{Sid}/Messages.json` (`{To, From, Body}`). +- **List messages:** `GET /2010-04-01/Accounts/{Sid}/Messages.json` (cursor-paginated via `PageSize` + `PageToken`, with a Twilio-style `next_page_uri`; filters `To`, `From`, `DateSent` (also `DateSent>`/`DateSent<` windows — queued messages with a null `date_sent` are excluded by date filters, like the real API)). - **Retrieve message:** `GET .../Messages/{Sid}.json`. -- **Create call:** `POST /2010-06-01/Accounts/{Sid}/Calls.json` (`{To, From, Url}`). +- **Create call:** `POST /2010-04-01/Accounts/{Sid}/Calls.json` (`{To, From, Url}`). - **Verify:** `POST /v2/Services/{ServiceSid}/Verification` → `{status:"pending"}`. - **Verify check:** `POST /v2/Services/{ServiceSid}/VerificationCheck` (`{To, Code}`) → `{status:"approved"}` on correct code. @@ -43,20 +43,20 @@ header: ``` AccountSid = AC0123456789abcdef0123456789abcdef -AuthToken = twilio_auth_token +AuthToken = feed0000face1111beef2222cafe3333 ``` -Base64 of `AC0123456789abcdef0123456789abcdef:twilio_auth_token`: +Base64 of `AC0123456789abcdef0123456789abcdef:feed0000face1111beef2222cafe3333`: ``` -QUMwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjp0d2lsaW9fYXV0aF90b2tlbg== +QUMwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjpmZWVkMDAwMGZhY2UxMTExYmVlZjIyMjJjYWZlMzMzMw== ``` ### Example ```bash -curl -u "AC0123456789abcdef0123456789abcdef:twilio_auth_token" \ - http://localhost:PORT/2010-06-01/Accounts/AC0123456789abcdef0123456789abcdef/Messages.json \ +curl -u "AC0123456789abcdef0123456789abcdef:feed0000face1111beef2222cafe3333" \ + http://localhost:PORT/2010-04-01/Accounts/AC0123456789abcdef0123456789abcdef/Messages.json \ -d 'To=+15551234567' \ -d 'From=+15557654321' \ -d 'Body=Hello from stunt' @@ -65,7 +65,7 @@ curl -u "AC0123456789abcdef0123456789abcdef:twilio_auth_token" \ ### 401 without auth ```bash -curl http://localhost:PORT/2010-06-01/Accounts/AC.../Messages.json +curl http://localhost:PORT/2010-04-01/Accounts/AC.../Messages.json # → 401 {"code":20003,"message":"Missing or invalid Basic Auth credentials",...} ``` @@ -120,22 +120,25 @@ registered webhook sink. See the stunt docs for webhook configuration ### Signed deliveries — `X-Twilio-Signature` -Webhook deliveries are signed exactly the way Twilio signs its webhook -requests — the header carries a base64 HMAC-SHA1 over the delivery URL plus -the raw request body: +Webhook deliveries use Twilio's real status-callback shape: the message +resource as **form parameters** (`AccountSid`, `ApiVersion`, `From`, +`MessageSid`, `MessageStatus`, `To`), signed with a base64 HMAC-SHA1 over +the delivery URL plus the parameters **sorted by key, each key immediately +followed by its (decoded) value**: ``` -X-Twilio-Signature = base64(HMAC-SHA1(key=twilio_auth_token, - msg=events_target_url + raw_body)) +X-Twilio-Signature = base64(HMAC-SHA1(key=feed0000face1111beef2222cafe3333, + msg=url + concat(sorted, key + value))) ``` The URL is the webhook destination configured as this service's -`events_target` (Twilio MACs the full request URL, so a receiver must validate -against the same URL stunt delivered to), and the body is the exact JSON -envelope on the wire. The signing key is the documented mock AuthToken: +`events_target` — Twilio MACs the full request URL, so a receiver must +validate against the same URL stunt delivered to. A receiver built from +Twilio's validation documentation verifies every delivery as-is. The +signing key is the documented mock AuthToken: ``` -twilio_auth_token +feed0000face1111beef2222cafe3333 ``` A receiver can therefore exercise real signature-verification code paths @@ -148,10 +151,10 @@ their deliveries and their mock secrets. | Method | Route | Handler | Description | |--------|-------|---------|-------------| -| POST | `/2010-06-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_send_message` | Send a message (→ `queued`) | -| GET | `/2010-06-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_list_messages` | List messages (stateful, cursor-paginated) | -| GET | `/2010-06-01/Accounts/{account_sid}/Messages/{sid}.json` | `messages.star#on_get_message` | Retrieve a message | -| POST | `/2010-06-01/Accounts/{account_sid}/Calls.json` | `calls.star#on_create_call` | Create a call (→ `queued`) | +| POST | `/2010-04-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_send_message` | Send a message (→ `queued`) | +| GET | `/2010-04-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_list_messages` | List messages (stateful, cursor-paginated) | +| GET | `/2010-04-01/Accounts/{account_sid}/Messages/{sid}.json` | `messages.star#on_get_message` | Retrieve a message | +| POST | `/2010-04-01/Accounts/{account_sid}/Calls.json` | `calls.star#on_create_call` | Create a call (→ `queued`) | | POST | `/v2/Services/{service_sid}/Verification` | `verify.star#on_create_verification` | Start a verification | | POST | `/v2/Services/{service_sid}/VerificationCheck` | `verify.star#on_check_verification` | Check a verification code | diff --git a/adapters/twilio-style/adapter.yaml b/adapters/twilio-style/adapter.yaml index 07616d38..4c56f8d4 100644 --- a/adapters/twilio-style/adapter.yaml +++ b/adapters/twilio-style/adapter.yaml @@ -10,26 +10,26 @@ version: "0.1.0" api: name: "Twilio API" - version: "2010-06-01" + version: "2010-04-01" # Endpoints — each maps a route + method to a Starlark handler. # NOTE: literal routes must come BEFORE parameterized routes so they are # matched first (the dispatch engine checks in declaration order). endpoints: # --- Messages (stateful) --- - - route: /2010-06-01/Accounts/{account_sid}/Messages.json + - route: /2010-04-01/Accounts/{account_sid}/Messages.json method: POST handler: scripts/messages.star#on_send_message - - route: /2010-06-01/Accounts/{account_sid}/Messages.json + - route: /2010-04-01/Accounts/{account_sid}/Messages.json method: GET handler: scripts/messages.star#on_list_messages - - route: /2010-06-01/Accounts/{account_sid}/Messages/{sid} + - route: /2010-04-01/Accounts/{account_sid}/Messages/{sid} method: GET handler: scripts/messages.star#on_get_message concurrency_key: sid # --- Calls --- - - route: /2010-06-01/Accounts/{account_sid}/Calls.json + - route: /2010-04-01/Accounts/{account_sid}/Calls.json method: POST handler: scripts/calls.star#on_create_call diff --git a/adapters/twilio-style/scripts/calls.star b/adapters/twilio-style/scripts/calls.star index 9b2c6dd2..56b4a223 100644 --- a/adapters/twilio-style/scripts/calls.star +++ b/adapters/twilio-style/scripts/calls.star @@ -1,6 +1,6 @@ # Calls handler — create a call. # -# POST /2010-06-01/Accounts/{account_sid}/Calls.json +# POST /2010-04-01/Accounts/{account_sid}/Calls.json # JSON { To, From, Url } -> { sid:"CA...", status:"queued", ... } # Shared helpers (_require_auth, _next_sid) are preloaded from @@ -37,11 +37,11 @@ def on_create_call(req): "from": frm, "status": "queued", "direction": "outbound-api", - "api_version": "2010-06-01", + "api_version": "2010-04-01", "price": "-0.01500", "price_unit": "USD", "duration": "0", - "uri": "/2010-06-01/Accounts/" + account_sid + "/Calls/" + sid + ".json", + "uri": "/2010-04-01/Accounts/" + account_sid + "/Calls/" + sid + ".json", "date_created": "Mon, 01 Jan 2024 00:00:00 +0000", "date_updated": "Mon, 01 Jan 2024 00:00:00 +0000", "parent_call_sid": None, diff --git a/adapters/twilio-style/scripts/lib.star b/adapters/twilio-style/scripts/lib.star index 7d99acf7..a635ce4f 100644 --- a/adapters/twilio-style/scripts/lib.star +++ b/adapters/twilio-style/scripts/lib.star @@ -9,7 +9,7 @@ # as the password. These are well-known synthetic test credentials for the # local simulator. ACCOUNT_SID = "AC" + "0123456789abcdef0123456789abcdef" -AUTH_TOKEN = "twilio_auth_token" +AUTH_TOKEN = "feed0000face1111beef2222cafe3333" # _basic_auth extracts and validates HTTP Basic credentials. # @@ -162,17 +162,88 @@ def _list_page(req, items): cursor = "" return paginate(items, limit, cursor) -# _signed_emit MACs the exact on-wire body and delivers with X-Twilio-Signature -# (Twilio's scheme): base64(HMAC-SHA1(AUTH_TOKEN, url + body)). The URL is the -# webhook destination (events_target) — Twilio MACs the request URL + raw body, -# so the receiver must validate against the same URL stunt delivered to. -def _signed_emit(event_type, payload): - body = events_body(event_type, payload) +# _signed_emit delivers Twilio's REAL status-callback shape: the message +# resource as form parameters, signed with +# base64(HMAC-SHA1(AUTH_TOKEN, url + params sorted by key, each key +# immediately followed by its raw value)) — the formula real receivers +# validate. events_emit_raw puts the exact pre-signed bytes on the wire. +_FORM_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123" + "456789" + "-_.~" +_HEX = "0123456789ABCDEF" +# Callback params are sanitized to printable ASCII: Starlark ord() is +# rune-based (a lone non-ASCII byte reads as U+FFFD), so a raw byte-exact +# encoder is impossible — mapping non-ASCII to '?' deterministically keeps +# the SIGNATURE string and the encoded BODY in agreement (both use the +# sanitized value), so receivers still verify. Real From/To addresses and +# alphanumeric sender IDs are ASCII. +def _ascii_safe(s): + out = "" + for i in range(len(s)): + c = s[i] + v = ord(c) + if v >= 32 and v <= 126: + out = out + c + else: + out = out + "?" + return out + +def _form_encode(s): + out = "" + for i in range(len(s)): + c = s[i] + if _FORM_SAFE.find(c) >= 0: + out = out + c + else: + v = ord(c) + if v > 255: + v = 63 + out = out + "%" + _HEX[v // 16] + _HEX[v % 16] + return out + +def _signed_emit(event_type, msg): url = events_target() if url == None: url = "" - sig = crypto.hmac_sha1(AUTH_TOKEN, url + body, encoding="base64") - events_emit(event_type, payload, {"X-Twilio-Signature": sig}) + else: + # The receiver reconstructs the signed URL from r.Host + the + # request URI, which always carries at least "/" — a pathless + # target would sign differently than the receiver sees. + scheme_end = url.find("://") + rest = url[scheme_end + 3:] if scheme_end >= 0 else url + if rest.find("/") < 0: + url = url + "/" + params = { + "AccountSid": ACCOUNT_SID, + "ApiVersion": "2010-04-01", + "From": _ascii_safe(msg.get("from", "")), + "MessageSid": msg.get("id", ""), + "MessageStatus": msg.get("status", ""), + "To": _ascii_safe(msg.get("to", "")), + } + keys = [] + for k in params: + keys.append(k) + # insertion sort (Starlark has no list.sort) + for i in range(1, len(keys)): + k = keys[i] + j = i - 1 + while j >= 0 and keys[j] > k: + keys[j + 1] = keys[j] + j = j - 1 + keys[j + 1] = k + signing = url + pairs = [] + for i in range(len(keys)): + k = keys[i] + signing = signing + k + params[k] + pairs.append(_form_encode(k) + "=" + _form_encode(params[k])) + body = "" + for i in range(len(pairs)): + if i > 0: + body = body + "&" + body = body + pairs[i] + sig = crypto.hmac_sha1(AUTH_TOKEN, signing, encoding="base64") + events_emit_raw(event_type, body, {"X-Twilio-Signature": sig, + "Content-Type": "application/x-www-form-urlencoded"}) # ============================================================================ # ASYNC MESSAGE LIFECYCLE (derive-on-read state machine) diff --git a/adapters/twilio-style/scripts/messages.star b/adapters/twilio-style/scripts/messages.star index 5bc7509b..cef321e9 100644 --- a/adapters/twilio-style/scripts/messages.star +++ b/adapters/twilio-style/scripts/messages.star @@ -1,10 +1,10 @@ # Messages handlers — stateful send, list, and retrieve. # -# POST /2010-06-01/Accounts/{account_sid}/Messages.json +# POST /2010-04-01/Accounts/{account_sid}/Messages.json # JSON { To, From, Body } -> { sid:"SM...", body, status:"queued", ... } -# GET /2010-06-01/Accounts/{account_sid}/Messages.json +# GET /2010-04-01/Accounts/{account_sid}/Messages.json # -> { first_page_uri, next_page_uri, messages: [...] } -# GET /2010-06-01/Accounts/{account_sid}/Messages/{sid}.json +# GET /2010-04-01/Accounts/{account_sid}/Messages/{sid}.json # -> { sid, body, status, ... } # # Messages are STATEFUL: a message POSTed appears in the GET list. @@ -62,10 +62,10 @@ def on_send_message(req): "body": msg_body, "status": "queued", "direction": "outbound-api", - "api_version": "2010-06-01", + "api_version": "2010-04-01", "price": "-0.00750", "price_unit": "USD", - "uri": "/2010-06-01/Accounts/" + account_sid + "/Messages/" + sid + ".json", + "uri": "/2010-04-01/Accounts/" + account_sid + "/Messages/" + sid + ".json", "date_created": "Mon, 01 Jan 2024 00:00:00 +0000", "date_sent": None, "date_updated": "Mon, 01 Jan 2024 00:00:00 +0000", @@ -144,15 +144,15 @@ def on_list_messages(req): next_page_uri = None if next_cursor != None: - next_page_uri = "/2010-06-01/Accounts/" + account_sid + "/Messages.json?Page=0&PageSize=" + str(page_size) + "&PageToken=" + next_cursor + next_page_uri = "/2010-04-01/Accounts/" + account_sid + "/Messages.json?Page=0&PageSize=" + str(page_size) + "&PageToken=" + next_cursor return respond(200, { - "first_page_uri": "/2010-06-01/Accounts/" + account_sid + "/Messages.json?Page=0&PageSize=" + str(page_size), + "first_page_uri": "/2010-04-01/Accounts/" + account_sid + "/Messages.json?Page=0&PageSize=" + str(page_size), "next_page_uri": next_page_uri, "page": 0, "page_size": page_size, "previous_page_uri": None, - "uri": "/2010-06-01/Accounts/" + account_sid + "/Messages.json", + "uri": "/2010-04-01/Accounts/" + account_sid + "/Messages.json", "messages": page, }) @@ -227,5 +227,7 @@ def _advance_message(msg): msg["date_updated"] = _SENT_AT msg["_stage"] = stage c.update(msg["sid"], msg) - _signed_emit("message." + msg["status"], _public_view(msg)) + # Raw doc, not the public view — _signed_emit reads the internal + # id/from/status/to fields to build the callback params. + _signed_emit("message." + msg["status"], msg) return msg diff --git a/conformance/go.mod b/conformance/go.mod index 571fdc1a..722848c8 100644 --- a/conformance/go.mod +++ b/conformance/go.mod @@ -3,20 +3,26 @@ module stuntapi.com/stunt/conformance // aws-sdk-go-v2/config v1.32.37 requires go >= 1.24. CI's setup-go pins // 1.23.3 and GOTOOLCHAIN=auto upgrades transparently; a GOTOOLCHAIN=local // environment needs a 1.24+ toolchain for `just conformance` only. -go 1.24.0 +go 1.25.0 require ( github.com/aws/aws-sdk-go-v2/config v1.32.37 github.com/aws/aws-sdk-go-v2/credentials v1.19.36 github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 + github.com/bold-commerce/go-shopify/v3 v3.17.0 github.com/google/go-github/v66 v66.0.0 github.com/stripe/stripe-go/v86 v86.3.0 - golang.org/x/oauth2 v0.30.0 + github.com/twilio/twilio-go v1.30.9 + golang.org/x/oauth2 v0.36.0 + google.golang.org/api v0.293.0 stuntapi.com/stunt v0.46.0 ) require ( + cloud.google.com/go/auth v0.23.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/aws/aws-sdk-go-v2 v1.43.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect @@ -33,22 +39,39 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect github.com/aws/smithy-go v1.27.8 // indirect github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.15 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/expr-lang/expr v1.17.8 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang/mock v1.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 // indirect github.com/vektah/gqlparser/v2 v2.5.36 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.starlark.net v0.0.0-20240925182052-1207426daebd // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect - golang.org/x/net v0.45.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect - google.golang.org/grpc v1.72.2 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect + google.golang.org/grpc v1.83.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.61.13 // indirect diff --git a/conformance/go.sum b/conformance/go.sum index da18779f..a5a3b389 100644 --- a/conformance/go.sum +++ b/conformance/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54= +cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= @@ -38,24 +44,36 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0nd github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bold-commerce/go-shopify/v3 v3.17.0 h1:1qZenleSsJMVFh5hu6R2z2NfmWP5xG0P8MawePr46K0= +github.com/bold-commerce/go-shopify/v3 v3.17.0/go.mod h1:qOrEfYoy5RRO/PAq4vGyHW03NZmt2iX/fPGuaZwemtI= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -63,73 +81,132 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk= +github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= +github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= +github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 h1:Pm6R878vxWWWR+Sa3ppsLce/Zq+JNTs6aVvRu13jv9A= +github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/stripe-go/v86 v86.3.0 h1:BKtYc3NtRa4EGzKAmp4jvl5q7kk2rwMZ+llF18N5vHI= github.com/stripe/stripe-go/v86 v86.3.0/go.mod h1:Co7QRXCKGNOPTugAdvjgRo+KcMtd9hxy+pZMN0yThsQ= +github.com/twilio/twilio-go v1.30.9 h1:4W4GEV2q0sLQ9xsr1N/97JQlt0c82hZ0ij4qTErstv8= +github.com/twilio/twilio-go v1.30.9/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg= github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.starlark.net v0.0.0-20240925182052-1207426daebd h1:S+EMisJOHklQxnS3kqsY8jl2y5aF0FDEdcLnOw3q22E= go.starlark.net v0.0.0-20240925182052-1207426daebd/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w= +google.golang.org/api v0.293.0/go.mod h1:6n5tjEB1gzwniZTepZ0g5u+wM7Bof5GeULCx/zh8ZE0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= diff --git a/conformance/google_test.go b/conformance/google_test.go new file mode 100644 index 00000000..e3604f0a --- /dev/null +++ b/conformance/google_test.go @@ -0,0 +1,168 @@ +package conformance + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" + "google.golang.org/api/idtoken" + "google.golang.org/api/option" +) + +// TestGoogleOAuthConformance drives Google's own auth stack (golang.org/x/ +// oauth2 + the idtoken verifier from google-api-go-client) against the +// google-style adapter: the full authorization-code exchange, refresh +// with rotation, and — the marquee — idtoken.Validate verifying the +// adapter's RS256 id_token against the JWKS the adapter itself serves. +func TestGoogleOAuthConformance(t *testing.T) { + ctx := context.Background() + base := Boot(t, "google-style") + + // The idtoken validator fetches Google's certs URL; a rewrite client + // pointing googleapis.com at the adapter makes it verify against the + // adapter-served /oauth2/v3/certs instead. + googleClient := &http.Client{ + Timeout: 30 * 1000 * 1000 * 1000, + Transport: &rewriteTransport{to: mustURL(t, base), base: http.DefaultTransport}, + } + + conf := &oauth2.Config{ + ClientID: "conformance-client", + ClientSecret: "conformance-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: base + "/o/oauth2/auth", + TokenURL: base + "/o/oauth2/token", + }, + RedirectURL: "http://localhost:9090/callback", + Scopes: []string{"openid", "email"}, + } + + // ===== Walk the authorize redirect to mint a code (the real first hop) ===== + + authURL := conf.AuthCodeURL("conformance-state") + // Do NOT follow the 302 — the redirect_uri is the client's own + // callback (unroutable here); the code lives in the Location header. + noRedirect := *googleClient + noRedirect.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + resp, err := noRedirect.Get(authURL) + if err != nil { + t.Fatalf("authorize: %v", err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + loc := resp.Header.Get("Location") + if resp.StatusCode != 302 || loc == "" { + t.Fatalf("authorize -> %d Location=%q", resp.StatusCode, loc) + } + code := locQuery(t, loc, "code") + if code == "" { + t.Fatalf("authorize redirect carries no code: %s", loc) + } + Record(t, "x/oauth2", "google-style", "authorize redirect mints a single-use code") + + // ===== The token exchange (the x/oauth2 library does the POST) ===== + + tok, err := conf.Exchange(context.WithValue(ctx, oauth2.HTTPClient, googleClient), code) + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if tok.AccessToken == "" { + t.Fatal("no access token") + } + idTok, hasID := tok.Extra("id_token").(string) + if !hasID || idTok == "" { + t.Fatal("exchange response carries no id_token (openid scope)") + } + Record(t, "x/oauth2", "google-style", "authorization-code exchange -> tokens + id_token") + + // ===== idtoken.Validate — Google's own verifier against the adapter's JWKS ===== + + validator, err := idtoken.NewValidator(ctx, option.WithHTTPClient(googleClient)) + if err != nil { + t.Fatalf("idtoken.NewValidator: %v", err) + } + payload, err := validator.Validate(ctx, idTok, "conformance-client") + if err != nil { + t.Fatalf("idtoken.Validate (Google's RS256+JWKS verifier): %v", err) + } + if payload.Issuer == "" || payload.Subject == "" { + t.Fatalf("validated payload: %+v", payload) + } + Record(t, "google-api-go-client/idtoken", "google-style", "idtoken.Validate verifies the adapter's RS256 id_token via its JWKS") + + // A tampered id_token must FAIL the same verifier. + tampered := idTok[:len(idTok)-6] + "AAAAAA" + if _, err := validator.Validate(ctx, tampered, "conformance-client"); err == nil { + t.Fatal("tampered id_token passed idtoken.Validate") + } + Record(t, "google-api-go-client/idtoken", "google-style", "tampered id_token rejected by idtoken.Validate") + + // ===== Refresh with rotation (the old token dies) ===== + + // Expire the cached token so TokenSource performs a real refresh + // grant (otherwise x/oauth2 just returns its cache). + tok.Expiry = time.Now().Add(-time.Minute) + src := conf.TokenSource(ctx, tok) + refreshed, err := src.Token() + if err != nil { + t.Fatalf("refresh: %v", err) + } + if refreshed.AccessToken == tok.AccessToken { + t.Fatal("refresh returned the same access token (no rotation)") + } + Record(t, "x/oauth2", "google-style", "refresh grant rotates the access token") + + // The userinfo endpoint honors the refreshed token. + ureq, _ := http.NewRequest("GET", base+"/oauth2/v3/userinfo", nil) + ureq.Header.Set("Authorization", "Bearer "+refreshed.AccessToken) + uresp, err := googleClient.Do(ureq) + if err != nil { + t.Fatalf("userinfo: %v", err) + } + ub, _ := io.ReadAll(uresp.Body) + uresp.Body.Close() + if uresp.StatusCode != 200 || !strings.Contains(string(ub), "sub") { + t.Fatalf("userinfo with refreshed token -> %d: %s", uresp.StatusCode, ub) + } + Record(t, "x/oauth2", "google-style", "userinfo honors the refreshed token") + + // The JWKS the validator used is the adapter's own. + cresp, err := googleClient.Get(base + "/oauth2/v3/certs") + if err != nil { + t.Fatal(err) + } + cb, _ := io.ReadAll(cresp.Body) + cresp.Body.Close() + var jwks struct { + Keys []json.RawMessage `json:"keys"` + } + if err := json.Unmarshal(cb, &jwks); err != nil || len(jwks.Keys) == 0 { + t.Fatalf("adapter JWKS: %v (%s)", err, cb) + } + Record(t, "google-api-go-client/idtoken", "google-style", "adapter serves a real JWKS") +} + +func locQuery(t *testing.T, loc, key string) string { + t.Helper() + u, err := url.Parse(loc) + if err != nil { + t.Fatalf("parse redirect %s: %v", loc, err) + } + return u.Query().Get(key) +} + +func mustURL(t *testing.T, s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + t.Fatalf("parse %s: %v", s, err) + } + return u +} diff --git a/conformance/harness.go b/conformance/harness.go index 6f554724..7be0a95d 100644 --- a/conformance/harness.go +++ b/conformance/harness.go @@ -118,3 +118,35 @@ func TestMain(m *testing.M) { func parseURL(s string) (*url.URL, error) { return url.Parse(s) } + +// RewriteClient returns an *http.Client that rewrites every request to +// the stunt base (scheme+host), preserving path and query — the seam for +// SDKs that hardcode their provider's host but accept a custom HTTP +// client (twilio-go ClientParams.HTTPClient, go-shopify WithHTTPClient). +// Only safe for SDKs that do NOT sign the request URL. +func RewriteClient(t *testing.T, base string) *http.Client { + t.Helper() + u, err := url.Parse(base) + if err != nil { + t.Fatalf("parse %s: %v", base, err) + } + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &rewriteTransport{to: u, base: http.DefaultTransport}, + } +} + +type rewriteTransport struct { + to *url.URL + base http.RoundTripper +} + +func (rt *rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) { + u := *r.URL + u.Scheme = rt.to.Scheme + u.Host = rt.to.Host + r2 := r.Clone(r.Context()) + r2.URL = &u + r2.Host = rt.to.Host + return rt.base.RoundTrip(r2) +} diff --git a/conformance/shopify_test.go b/conformance/shopify_test.go new file mode 100644 index 00000000..0c0e871b --- /dev/null +++ b/conformance/shopify_test.go @@ -0,0 +1,157 @@ +package conformance + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + goshopify "github.com/bold-commerce/go-shopify/v3" +) + +// TestShopifySDKConformance drives go-shopify (bold-commerce, the standard +// Go client) against the shopify-style adapter with the documented static +// token: order create + Link-header cursor pagination through the SDK's +// NextPageOptions, and webhook delivery verified by the SDK's own +// VerifyWebhookRequest HMAC validator. +func TestShopifySDKConformance(t *testing.T) { + var mu sync.Mutex + var deliveries []struct { + body []byte + headers http.Header + } + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + deliveries = append(deliveries, struct { + body []byte + headers http.Header + }{b, r.Header.Clone()}) + mu.Unlock() + w.WriteHeader(200) + })) + defer sink.Close() + + base := Boot(t, "shopify-style", sink.URL) + + app := goshopify.App{ApiSecret: "shpss_stunt_mock_api_client_secret"} + client := goshopify.NewClient(app, "conformance", "shpat_test", + goshopify.WithHTTPClient(RewriteClient(t, base)), + goshopify.WithVersion("2024-10")) + + // ===== Register a webhook BEFORE creating orders (deliveries gate on it) ===== + + hook, err := client.Webhook.Create(goshopify.Webhook{ + Address: sink.URL, + Topic: "orders/create", + Format: "json", + }) + if err != nil { + t.Fatalf("Webhook.Create: %v", err) + } + if hook.ID == 0 { + t.Fatal("webhook id not assigned") + } + Record(t, "go-shopify/v3", "shopify-style", "Webhook.Create (orders/create)") + + // ===== Order creates ===== + + for i := 1; i <= 4; i++ { + order, err := client.Order.Create(goshopify.Order{ + LineItems: []goshopify.LineItem{ + {Title: fmt.Sprintf("Widget %d", i), Quantity: i}, + }, + FinancialStatus: "pending", + }) + if err != nil { + t.Fatalf("Order.Create %d: %v", i, err) + } + if order.ID == 0 { + t.Fatalf("order %d: no id assigned", i) + } + } + Record(t, "go-shopify/v3", "shopify-style", "Order.Create x4 (numeric ids)") + + // The most common real pattern: an order carrying an embedded + // customer. The id round-trips numeric (regression: a float/int + // customer id used to crash the view and poison every later list). + withCustomer, err := client.Order.Create(goshopify.Order{ + LineItems: []goshopify.LineItem{{Title: "For someone", Quantity: 1}}, + Customer: &goshopify.Customer{ID: 42, Email: "buyer@example.test"}, + }) + if err != nil { + t.Fatalf("Order.Create with customer: %v", err) + } + if withCustomer.Customer == nil || withCustomer.Customer.ID != 42 { + t.Fatalf("embedded customer id = %+v, want 42", withCustomer.Customer) + } + // And the list still renders every order afterwards. + if _, _, err := client.Order.ListWithPagination(nil); err != nil { + t.Fatalf("Order.List after embedded-customer create: %v", err) + } + Record(t, "go-shopify/v3", "shopify-style", "Order.Create with embedded customer (numeric id round-trip)") + + // ===== Cursor pagination through the SDK's Link-header walking ===== + + options := &goshopify.ListOptions{Limit: 2} + var collected []goshopify.Order + pages := 0 + for { + page, pagination, err := client.Order.ListWithPagination(options) + if err != nil { + t.Fatalf("Order.ListWithPagination: %v", err) + } + collected = append(collected, page...) + pages++ + if pagination == nil || pagination.NextPageOptions == nil { + break + } + options = pagination.NextPageOptions + } + // The seed ships one pre-existing order; ours are 4 more. + if len(collected) < 4 { + t.Fatalf("paginated %d orders, want >= 4 (page_info cursor not followed?)", len(collected)) + } + if pages < 2 { + t.Fatalf("walked %d pages with Limit=2 over %d orders — cursor not followed", pages, len(collected)) + } + Record(t, "go-shopify/v3", "shopify-style", fmt.Sprintf("Order.ListWithPagination walks page_info cursors (%d orders, %d pages)", len(collected), pages)) + + // ===== Webhook deliveries verified by the SDK's own HMAC validator ===== + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(deliveries) + mu.Unlock() + if n >= 4 { + break + } + time.Sleep(200 * time.Millisecond) + } + mu.Lock() + defer mu.Unlock() + if len(deliveries) < 4 { + t.Fatalf("only %d orders/create deliveries arrived, want >= 4", len(deliveries)) + } + for i, d := range deliveries { + req, err := http.NewRequest("POST", sink.URL, bytes.NewReader(d.body)) + if err != nil { + t.Fatal(err) + } + for k, vs := range d.headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + if ok, err := app.VerifyWebhookRequestVerbose(req); err != nil || !ok { + t.Fatalf("delivery %d failed the SDK's VerifyWebhookRequest: ok=%v err=%v (X-Shopify-Hmac-Sha256=%q)", + i, ok, err, d.headers.Get("X-Shopify-Hmac-Sha256")) + } + } + Record(t, "go-shopify/v3", "shopify-style", "webhooks verify through the SDK's VerifyWebhookRequest HMAC validator") +} diff --git a/conformance/twilio_test.go b/conformance/twilio_test.go new file mode 100644 index 00000000..5e517470 --- /dev/null +++ b/conformance/twilio_test.go @@ -0,0 +1,231 @@ +package conformance + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/twilio/twilio-go" + tclient "github.com/twilio/twilio-go/client" + v2010 "github.com/twilio/twilio-go/rest/api/v2010" +) + +// twilioMockAuthToken mirrors the adapter's documented mock auth token — +// real-looking 32-hex, because official SDKs (twilio-go) client-side +// validate credentials as alphanumeric and reject underscore tokens. +const twilioMockAuthToken = "feed0000face1111beef2222cafe3333" + +// TestTwilioSDKConformance drives the official twilio-go SDK against the +// twilio-style adapter with the documented test credentials: message +// create, the queued->sent->delivered lifecycle through SDK Fetch polling, +// the magic invalid-number failure trigger, list filters, and a signed +// status-callback webhook verified with Twilio's documented formula. +func TestTwilioSDKConformance(t *testing.T) { + base := Boot(t, "twilio-style") + + const sid = "AC" + "0123456789abcdef0123456789abcdef" + client := newTwilioClient(t, base, sid) + + // ===== Create + lifecycle (queued -> sent -> delivered via SDK fetch) ===== + + msg, err := client.Api.CreateMessage(&v2010.CreateMessageParams{ + To: ptr("+15550002222"), + From: ptr("+15550001111"), + Body: ptr("conformance hello"), + }) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + msgSid := deref(msg.Sid) + if !strings.HasPrefix(msgSid, "SM") { + t.Fatalf("message Sid = %q, want SM prefix", msgSid) + } + Record(t, "twilio-go", "twilio-style", "CreateMessage (SM sid assigned)") + + var terminal string + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + m, err := client.Api.FetchMessage(msgSid, nil) + if err != nil { + t.Fatalf("FetchMessage: %v", err) + } + if st := deref(m.Status); st == "delivered" || st == "failed" || st == "sent" { + terminal = st + break + } + time.Sleep(300 * time.Millisecond) + } + if terminal != "delivered" && terminal != "sent" { + t.Fatalf("lifecycle poll ended at %q, want sent/delivered (derive-on-read transitions)", terminal) + } + Record(t, "twilio-go", "twilio-style", fmt.Sprintf("FetchMessage poll reaches terminal state (%s)", terminal)) + + // ===== The magic invalid number -> failed ===== + + bad, err := client.Api.CreateMessage(&v2010.CreateMessageParams{ + To: ptr("+15005550001"), + From: ptr("+15550001111"), + Body: ptr("should fail"), + }) + if err != nil { + t.Fatalf("CreateMessage invalid number: %v", err) + } + badSid := deref(bad.Sid) + badTerminal := "" + deadline = time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + m, err := client.Api.FetchMessage(badSid, nil) + if err != nil { + t.Fatalf("FetchMessage bad: %v", err) + } + if st := deref(m.Status); st != "queued" && st != "" { + badTerminal = st + break + } + time.Sleep(300 * time.Millisecond) + } + if badTerminal != "failed" { + t.Fatalf("invalid number terminal status = %q, want failed", badTerminal) + } + Record(t, "twilio-go", "twilio-style", "+15005550001 magic number -> failed") + + // ===== List with a To filter (only the matching message) ===== + + list, err := client.Api.ListMessage(&v2010.ListMessageParams{ + To: ptr("+15550002222"), + }) + if err != nil { + t.Fatalf("ListMessage: %v", err) + } + for _, m := range list { + if deref(m.To) != "+15550002222" { + t.Fatalf("filter leaked: message To=%q in a To=+15550002222 list", deref(m.To)) + } + } + if len(list) == 0 { + t.Fatal("To filter returned nothing") + } + Record(t, "twilio-go", "twilio-style", "ListMessage To filter honored") + + // ===== Signed status-callback webhook (Twilio's documented formula) ===== + + var mu sync.Mutex + var callbacks []struct { + url string + body string + headers http.Header + } + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + callbacks = append(callbacks, struct { + url string + body string + headers http.Header + }{"http://" + r.Host + r.URL.RequestURI(), string(b), r.Header.Clone()}) + mu.Unlock() + w.WriteHeader(200) + })) + defer sink.Close() + + // The adapter signs deliveries against the engine-registered sink. + cbBase := Boot(t, "twilio-style", sink.URL) + cbClient := newTwilioClient(t, cbBase, sid) + cbMsg, err := cbClient.Api.CreateMessage(&v2010.CreateMessageParams{ + To: ptr("+15550003333"), + From: ptr("+15550001111"), + Body: ptr("with callback"), + }) + if err != nil { + t.Fatalf("CreateMessage callback: %v", err) + } + // Callbacks fire per status TRANSITION and transitions are read-driven + // (derive-on-read) — poll this engine's message to delivered so BOTH + // the sent and delivered callbacks fire. + cbSid := deref(cbMsg.Sid) + deadline = time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + m, err := cbClient.Api.FetchMessage(cbSid, nil) + if err != nil { + t.Fatalf("FetchMessage cb: %v", err) + } + if st := deref(m.Status); st == "delivered" || st == "failed" { + break + } + time.Sleep(300 * time.Millisecond) + } + + deadline = time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(callbacks) + mu.Unlock() + if n >= 2 { // sent + delivered hops + break + } + time.Sleep(200 * time.Millisecond) + } + mu.Lock() + defer mu.Unlock() + if len(callbacks) == 0 { + t.Fatal("no status callbacks arrived at the sink") + } + for _, cb := range callbacks { + sig := cb.headers.Get("X-Twilio-Signature") + if sig == "" { + t.Fatal("callback without X-Twilio-Signature") + } + // base64(HMAC-SHA1(authToken, url + sorted form params)). + vals, err := url.ParseQuery(cb.body) + if err != nil { + t.Fatalf("callback body not a form: %v", err) + } + keys := make([]string, 0, len(vals)) + for k := range vals { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + sb.WriteString(cb.url) + for _, k := range keys { + sb.WriteString(k + vals.Get(k)) + } + mac := hmac.New(sha1.New, []byte(twilioMockAuthToken)) + mac.Write([]byte(sb.String())) + want := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + if sig != want { + t.Fatalf("X-Twilio-Signature mismatch:\n got %q\nwant %q (url %s)", sig, want, cb.url) + } + } + Record(t, "twilio-go", "twilio-style", "status callbacks verify against Twilio's HMAC-SHA1 formula") +} + +// newTwilioClient builds the official client on the rewrite transport — +// the SDK hardcodes api.twilio.com, and accepts a custom BaseClient. +func newTwilioClient(t *testing.T, base, sid string) *twilio.RestClient { + t.Helper() + bc := &tclient.Client{ + Credentials: tclient.NewCredentials(sid, twilioMockAuthToken), + HTTPClient: RewriteClient(t, base), + } + bc.SetAccountSid(sid) + return twilio.NewRestClientWithParams(twilio.ClientParams{Client: bc}) +} + +func deref[T any](p *T) T { + if p == nil { + var zero T + return zero + } + return *p +} diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 98a4fadf..60be2b74 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -34,11 +34,11 @@ type Adapter struct { // APISpec records which real upstream API (and which version of it) an adapter // simulates. The version should match the real API's version/date stamp so users -// know exactly what shapes to expect (e.g. Twilio "2010-06-01", Stripe +// know exactly what shapes to expect (e.g. Twilio "2010-04-01", Stripe // "2024-06-20", Salesforce REST "v60.0"). type APISpec struct { Name string `yaml:"name"` // human-readable upstream API name, e.g. "Twilio API" - Version string `yaml:"version"` // specific upstream API version simulated, e.g. "2010-06-01" + Version string `yaml:"version"` // specific upstream API version simulated, e.g. "2010-04-01" } // WebsocketEndpoint declares a WebSocket route served by a connection- diff --git a/internal/contrib/lint/lint.go b/internal/contrib/lint/lint.go index b4944cf5..3d67ad77 100644 --- a/internal/contrib/lint/lint.go +++ b/internal/contrib/lint/lint.go @@ -293,7 +293,7 @@ func splitGraphqlHandler(h string) (path, fn string) { // // api: // name: "Twilio API" -// version: "2010-06-01" +// version: "2010-04-01" // // Missing or incomplete blocks are warnings (existing adapters without the // block are not broken), but every adapter SHOULD declare which real API @@ -325,7 +325,7 @@ func lintAPIBlock(dir string) []Finding { if apiNode == nil || apiNode.Kind != yaml.MappingNode { return []Finding{{ File: "adapter.yaml", Severity: SeverityWarn, - Message: "missing `api:` block — declare the real upstream API + version this adapter simulates (e.g. api: { name: \"Twilio API\", version: \"2010-06-01\" })", + Message: "missing `api:` block — declare the real upstream API + version this adapter simulates (e.g. api: { name: \"Twilio API\", version: \"2010-04-01\" })", }} } name, ver := "", "" diff --git a/internal/engine/sighelper_test.go b/internal/engine/sighelper_test.go index 4be12afb..a4e8e2fe 100644 --- a/internal/engine/sighelper_test.go +++ b/internal/engine/sighelper_test.go @@ -10,6 +10,8 @@ import ( "io" "net/http" "net/http/httptest" + neturl "net/url" + "sort" "strconv" "strings" "sync" @@ -137,14 +139,38 @@ func verifyWhatsAppSig(t *testing.T, raw []byte, hdr http.Header, secret string) // verifyTwilioSig re-derives base64(HMAC-SHA1(authToken, url+rawBody)) and // compares it to the X-Twilio-Signature header. Twilio MACs the request URL // concatenated with the raw body; url is the delivery URL stunt POSTed to. -func verifyTwilioSig(t *testing.T, raw []byte, hdr http.Header, authToken, url string) { +func verifyTwilioSig(t *testing.T, raw []byte, hdr http.Header, authToken, callbackURL string) { t.Helper() + // The receiver reconstructs the signed URL from r.Host + the request + // URI, which always carries at least "/" — normalize a pathless + // target the same way the adapter does. + if i := strings.Index(callbackURL, "://"); i >= 0 && !strings.Contains(callbackURL[i+3:], "/") { + callbackURL += "/" + } + // Twilio's real formula: url + the form params sorted by key, each + // key immediately followed by its (decoded) value. + vals, err := neturl.ParseQuery(string(raw)) + if err != nil { + t.Fatalf("callback body is not a form: %v (%s)", err, raw) + } + keys := make([]string, 0, len(vals)) + for k := range vals { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + sb.WriteString(callbackURL) + for _, k := range keys { + sb.WriteString(k + vals.Get(k)) + } mac := hmac.New(sha1.New, []byte(authToken)) - mac.Write([]byte(url)) - mac.Write(raw) + mac.Write([]byte(sb.String())) want := base64.StdEncoding.EncodeToString(mac.Sum(nil)) if got := hdr.Get("X-Twilio-Signature"); got != want { - t.Fatalf("X-Twilio-Signature = %q, want %q (url=%s)", got, want, url) + t.Fatalf("X-Twilio-Signature = %q, want %q (url=%s)", got, want, callbackURL) + } + if vals.Get("MessageStatus") == "" || vals.Get("MessageSid") == "" { + t.Fatalf("callback params missing MessageStatus/MessageSid: %v", vals) } } diff --git a/internal/engine/signing_more_test.go b/internal/engine/signing_more_test.go index 83d631f1..e46faa76 100644 --- a/internal/engine/signing_more_test.go +++ b/internal/engine/signing_more_test.go @@ -63,7 +63,7 @@ func TestWhatsAppStyleSignatureVerifies(t *testing.T) { // X-Twilio-Signature the real Twilio formula accepts: base64(HMAC-SHA1(auth // token, url+body)). The delivery URL is the configured webhook target. func TestTwilioStyleSignatureVerifies(t *testing.T) { - const authToken = "twilio_auth_token" + const authToken = "feed0000face1111beef2222cafe3333" sink := newCaptureSink() defer sink.close() @@ -92,7 +92,7 @@ func TestTwilioStyleSignatureVerifies(t *testing.T) { time.Sleep(50 * time.Millisecond) base := addrs["twilio"] - msgPath := base + "/2010-06-01/Accounts/" + twilioAccountSID + "/Messages.json" + msgPath := base + "/2010-04-01/Accounts/" + twilioAccountSID + "/Messages.json" body, status := twilioPostJSON(t, msgPath, map[string]any{ "To": "+15551234567", "From": "+15557654321", @@ -110,7 +110,7 @@ func TestTwilioStyleSignatureVerifies(t *testing.T) { // The message.sent status callback now fires when a read first derives // the sent stage (derive-on-read lifecycle, +1s after the POST). time.Sleep(1200 * time.Millisecond) - if _, status := twilioGet(t, base+"/2010-06-01/Accounts/"+twilioAccountSID+"/Messages/"+sid+".json"); status != 200 { + if _, status := twilioGet(t, base+"/2010-04-01/Accounts/"+twilioAccountSID+"/Messages/"+sid+".json"); status != 200 { t.Fatalf("GET Messages/%s.json -> %d, want 200", sid, status) } diff --git a/internal/engine/twilio_style_test.go b/internal/engine/twilio_style_test.go index 2a970c25..0b38df81 100644 --- a/internal/engine/twilio_style_test.go +++ b/internal/engine/twilio_style_test.go @@ -3,6 +3,8 @@ package engine import ( "bytes" "context" + "crypto/hmac" + "crypto/sha1" "encoding/base64" "encoding/json" "io" @@ -10,6 +12,7 @@ import ( "net/http/httptest" "net/url" "path/filepath" + "sort" "strings" "sync" "testing" @@ -21,7 +24,7 @@ import ( // Twilio synthetic test credentials (must match scripts/lib.star). const ( twilioAccountSID = "AC0123456789abcdef0123456789abcdef" - twilioAuthToken = "twilio_auth_token" + twilioAuthToken = "feed0000face1111beef2222cafe3333" ) // twilioBasicAuth returns the value for an HTTP Basic Authorization header. @@ -122,7 +125,7 @@ func TestTwilioStyleAdapter(t *testing.T) { base := addrs["twilio"] const accountSID = twilioAccountSID - msgPath := base + "/2010-06-01/Accounts/" + accountSID + "/Messages.json" + msgPath := base + "/2010-04-01/Accounts/" + accountSID + "/Messages.json" // ===== POST message → 201, sid SM..., status queued ===== @@ -151,8 +154,8 @@ func TestTwilioStyleAdapter(t *testing.T) { if msg["direction"] != "outbound-api" { t.Fatalf("message direction = %v, want outbound-api", msg["direction"]) } - if msg["api_version"] != "2010-06-01" { - t.Fatalf("message api_version = %v, want 2010-06-01", msg["api_version"]) + if msg["api_version"] != "2010-04-01" { + t.Fatalf("message api_version = %v, want 2010-04-01", msg["api_version"]) } // ===== GET message list → shows the sent message (STATEFUL) ===== @@ -185,7 +188,7 @@ func TestTwilioStyleAdapter(t *testing.T) { // ===== GET message by SID → persisted message ===== - body, status = twilioGet(t, base+"/2010-06-01/Accounts/"+accountSID+"/Messages/"+msgSID+".json") + body, status = twilioGet(t, base+"/2010-04-01/Accounts/"+accountSID+"/Messages/"+msgSID+".json") if status != 200 { t.Fatalf("GET message by sid -> status %d, want 200; body %s", status, body) } @@ -202,7 +205,7 @@ func TestTwilioStyleAdapter(t *testing.T) { // ===== GET nonexistent message → 404 ===== - _, status = twilioGet(t, base+"/2010-06-01/Accounts/"+accountSID+"/Messages/SMnotfound.json") + _, status = twilioGet(t, base+"/2010-04-01/Accounts/"+accountSID+"/Messages/SMnotfound.json") if status != 404 { t.Fatalf("GET nonexistent message -> status %d, want 404", status) } @@ -232,7 +235,7 @@ func TestTwilioStyleAdapter(t *testing.T) { time.Sleep(3500 * time.Millisecond) // Normal message -> delivered, with date_sent now set. - body, status = twilioGet(t, base+"/2010-06-01/Accounts/"+accountSID+"/Messages/"+msgSID+".json") + body, status = twilioGet(t, base+"/2010-04-01/Accounts/"+accountSID+"/Messages/"+msgSID+".json") if status != 200 { t.Fatalf("GET delivered message -> status %d, want 200; body %s", status, body) } @@ -248,7 +251,7 @@ func TestTwilioStyleAdapter(t *testing.T) { } // Failure-injected message -> undelivered with an error code. - body, status = twilioGet(t, base+"/2010-06-01/Accounts/"+accountSID+"/Messages/"+failSID+".json") + body, status = twilioGet(t, base+"/2010-04-01/Accounts/"+accountSID+"/Messages/"+failSID+".json") if status != 200 { t.Fatalf("GET undelivered message -> status %d, want 200; body %s", status, body) } @@ -265,7 +268,7 @@ func TestTwilioStyleAdapter(t *testing.T) { // ===== POST call → 201, sid CA..., status queued ===== - callPath := base + "/2010-06-01/Accounts/" + accountSID + "/Calls.json" + callPath := base + "/2010-04-01/Accounts/" + accountSID + "/Calls.json" body, status = twilioPostJSON(t, callPath, map[string]any{ "To": "+15551234567", "From": "+15557654321", @@ -430,7 +433,7 @@ func TestTwilioStyleMessageFilters(t *testing.T) { base := addrs["twilio"] const accountSID = twilioAccountSID - msgPath := base + "/2010-06-01/Accounts/" + accountSID + "/Messages.json" + msgPath := base + "/2010-04-01/Accounts/" + accountSID + "/Messages.json" for _, to := range []string{"+15551110001", "+15551110001", "+15551110002"} { body, status := twilioPostJSON(t, msgPath, map[string]any{ @@ -487,15 +490,38 @@ func TestTwilioStyleMessageFilters(t *testing.T) { func TestTwilioStyleLifecycleEmitsOnce(t *testing.T) { var mu sync.Mutex var statuses []string + var sigOK int + var sigChecked int sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) - var ev map[string]any - if err := json.Unmarshal(b, &ev); err == nil { - if ty, ok := ev["type"].(string); ok { + // Real-callback contract: form params (MessageStatus=...) signed + // per Twilio's formula: base64(HMAC-SHA1(token, url + sorted + // key+value pairs)). + if vals, err := url.ParseQuery(string(b)); err == nil { + if st := vals.Get("MessageStatus"); st != "" { mu.Lock() - statuses = append(statuses, ty) + statuses = append(statuses, "message."+st) mu.Unlock() } + keys := make([]string, 0, len(vals)) + for k := range vals { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + sb.WriteString("http://" + r.Host + r.URL.RequestURI()) + for _, k := range keys { + sb.WriteString(k + vals.Get(k)) + } + mac := hmac.New(sha1.New, []byte(twilioAuthToken)) + mac.Write([]byte(sb.String())) + want := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + mu.Lock() + sigChecked++ + if r.Header.Get("X-Twilio-Signature") == want { + sigOK++ + } + mu.Unlock() } w.WriteHeader(200) })) @@ -529,7 +555,7 @@ func TestTwilioStyleLifecycleEmitsOnce(t *testing.T) { base := addrs["twilio"] const accountSID = twilioAccountSID - msgPath := base + "/2010-06-01/Accounts/" + accountSID + "/Messages.json" + msgPath := base + "/2010-04-01/Accounts/" + accountSID + "/Messages.json" body, status := twilioPostJSON(t, msgPath, map[string]any{ "To": "+15551234567", @@ -542,7 +568,7 @@ func TestTwilioStyleLifecycleEmitsOnce(t *testing.T) { var msg map[string]any _ = json.Unmarshal([]byte(body), &msg) sid := msg["sid"].(string) - single := base + "/2010-06-01/Accounts/" + accountSID + "/Messages/" + sid + ".json" + single := base + "/2010-04-01/Accounts/" + accountSID + "/Messages/" + sid + ".json" time.Sleep(3500 * time.Millisecond) @@ -569,4 +595,7 @@ func TestTwilioStyleLifecycleEmitsOnce(t *testing.T) { if counts["message.sent"] != 1 { t.Errorf("sent emitted %d times, want exactly 1 (statuses: %v)", counts["sent"], counts) } + if sigChecked == 0 || sigOK != sigChecked { + t.Errorf("signature verification: %d/%d callbacks carried a valid X-Twilio-Signature", sigOK, sigChecked) + } }