e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
217 lines
7.9 KiB
Go
217 lines
7.9 KiB
Go
package x402
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestVerifyAndSettle checks that Require verifies then settles against an
|
|
// HTTP facilitator and surfaces the settlement in both response headers.
|
|
func TestVerifyAndSettle(t *testing.T) {
|
|
var verifyHit, settleHit bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/verify":
|
|
verifyHit = true
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "payer": "0xabc"})
|
|
case "/settle":
|
|
settleHit = true
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xdeadbeef"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{PayTo: "0xpay", Network: "eip155:8453", FacilitatorURL: srv.URL}
|
|
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
|
r.Header.Set(PaymentHeader, base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453"}`)))
|
|
rec := httptest.NewRecorder()
|
|
|
|
if !cfg.Require(rec, r, "10000", "chat") {
|
|
t.Fatalf("Require returned false; body=%s", rec.Body.String())
|
|
}
|
|
if !verifyHit || !settleHit {
|
|
t.Fatalf("expected both verify and settle to be hit: verify=%v settle=%v", verifyHit, settleHit)
|
|
}
|
|
if got := rec.Header().Get(PaymentResponseHeader); got != "0xdeadbeef" {
|
|
t.Errorf("X-PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
|
|
}
|
|
if got := rec.Header().Get(PaymentResponseHeaderV2); got != "0xdeadbeef" {
|
|
t.Errorf("PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
|
|
}
|
|
}
|
|
|
|
// TestSettlementFailureChallenges checks that a failed settlement blocks the
|
|
// request with a fresh 402 rather than letting it through.
|
|
func TestSettlementFailureChallenges(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/verify":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
|
|
case "/settle":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "errorReason": "insufficient_funds"})
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
|
|
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
|
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
|
rec := httptest.NewRecorder()
|
|
|
|
if cfg.Require(rec, r, "10000", "chat") {
|
|
t.Fatal("Require should return false when settlement fails")
|
|
}
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Errorf("status = %d, want 402", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestPaymentSignatureHeaderAccepted checks the v2 request header is honored.
|
|
func TestPaymentSignatureHeaderAccepted(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "success": true, "transaction": "0x1"})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
|
|
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
|
r.Header.Set(PaymentHeaderV2, "eyJ4IjoxfQ==") // PAYMENT-SIGNATURE only
|
|
rec := httptest.NewRecorder()
|
|
|
|
if !cfg.Require(rec, r, "10000", "chat") {
|
|
t.Fatalf("Require should honor PAYMENT-SIGNATURE; body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRequirementsExtraForKnownNetwork checks the EIP-712 domain is filled in.
|
|
func TestRequirementsExtraForKnownNetwork(t *testing.T) {
|
|
for _, net := range []string{"base", "eip155:8453"} {
|
|
req := Config{PayTo: "0xpay", Network: net}.requirements("10000", "chat")
|
|
if req.Extra["name"] == "" || req.Extra["version"] == "" {
|
|
t.Errorf("network %q: extra not filled: %v", net, req.Extra)
|
|
}
|
|
if req.Asset == "" {
|
|
t.Errorf("network %q: asset not defaulted", net)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDecodePayment checks the base64 header is decoded to an object for the
|
|
// facilitator (which expects the payload object, not the raw string).
|
|
func TestDecodePayment(t *testing.T) {
|
|
enc := base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453","payload":{"x":1}}`))
|
|
obj := decodePayment(enc)
|
|
m, ok := obj.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("decodePayment did not return an object: %T", obj)
|
|
}
|
|
if m["network"] != "eip155:8453" {
|
|
t.Errorf("decoded network = %v", m["network"])
|
|
}
|
|
// Non-base64 passes through unchanged.
|
|
if got := decodePayment("not-base64!"); got != "not-base64!" {
|
|
t.Errorf("passthrough failed: %v", got)
|
|
}
|
|
}
|
|
|
|
// TestCDPBearer certifies the CDP JWT is well-formed and its signature verifies.
|
|
func TestCDPBearer(t *testing.T) {
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
secret := base64.StdEncoding.EncodeToString(priv)
|
|
|
|
tok, err := cdpBearer("key-id", secret, "POST", "api.cdp.coinbase.com", "/platform/v2/x402/verify")
|
|
if err != nil {
|
|
t.Fatalf("cdpBearer: %v", err)
|
|
}
|
|
parts := strings.Split(tok, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("want 3 JWT segments, got %d", len(parts))
|
|
}
|
|
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
|
|
t.Fatal("JWT signature does not verify")
|
|
}
|
|
var claims map[string]any
|
|
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
|
_ = json.Unmarshal(cb, &claims)
|
|
if claims["iss"] != "cdp" || claims["uri"] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" {
|
|
t.Errorf("bad claims: %v", claims)
|
|
}
|
|
}
|
|
|
|
// TestCDPAuthorizeAttachesBearer checks CDP() signs facilitator requests.
|
|
func TestCDPAuthorizeAttachesBearer(t *testing.T) {
|
|
_, priv, _ := ed25519.GenerateKey(nil)
|
|
fac := CDP("key-id", base64.StdEncoding.EncodeToString(priv))
|
|
req, _ := http.NewRequest(http.MethodPost, "https://api.cdp.coinbase.com/platform/v2/x402/verify", nil)
|
|
if err := fac.Authorize(req); err != nil {
|
|
t.Fatalf("authorize: %v", err)
|
|
}
|
|
if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ey") {
|
|
t.Errorf("missing Bearer JWT: %q", req.Header.Get("Authorization"))
|
|
}
|
|
}
|
|
|
|
// TestRequireSettlementFailsClosed checks that a paid config with
|
|
// RequireSettlement refuses to serve when the facilitator only verifies (does
|
|
// not settle) — otherwise the resource is released while no funds move.
|
|
func TestRequireSettlementFailsClosed(t *testing.T) {
|
|
// mockFacilitator implements Verify but not Settler.
|
|
cfg := Config{PayTo: "0xpay", Facilitator: mockFacilitator{valid: true}, RequireSettlement: true}
|
|
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
|
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
|
rec := httptest.NewRecorder()
|
|
|
|
if cfg.Require(rec, r, "10000", "chat") {
|
|
t.Fatal("Require should fail closed when settlement is required but unavailable")
|
|
}
|
|
if rec.Code != http.StatusPaymentRequired {
|
|
t.Errorf("status = %d, want 402", rec.Code)
|
|
}
|
|
|
|
// Without RequireSettlement the verify-only facilitator still serves.
|
|
cfg.RequireSettlement = false
|
|
rec = httptest.NewRecorder()
|
|
r = httptest.NewRequest(http.MethodGet, "/tool", nil)
|
|
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
|
if !cfg.Require(rec, r, "10000", "chat") {
|
|
t.Fatalf("verify-only should serve when settlement is not required; body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRequireSettlementServesWithSettler checks that a paid config with
|
|
// RequireSettlement serves when the facilitator can settle.
|
|
func TestRequireSettlementServesWithSettler(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/verify":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
|
|
case "/settle":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xabc"})
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// HTTPFacilitator implements Settler.
|
|
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL, RequireSettlement: true}
|
|
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
|
|
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
|
|
rec := httptest.NewRecorder()
|
|
|
|
if !cfg.Require(rec, r, "10000", "chat") {
|
|
t.Fatalf("Require should serve with a settling facilitator; body=%s", rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get(PaymentResponseHeader); got != "0xabc" {
|
|
t.Errorf("settlement header = %q, want 0xabc", got)
|
|
}
|
|
}
|
|
|
|
var _ Settler = (*HTTPFacilitator)(nil)
|