naco-api/internal/x402/x402_test.go
2026-08-03 11:08:53 +00:00

208 lines
5.6 KiB
Go

package x402
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
)
type DummyAuthProvider struct{}
func (d *DummyAuthProvider) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
return x402http.AuthHeaders{}, nil
}
func TestX402Server_HTTPBoundaries(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{
Name: "getTestPost",
Description: "Gated test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
reqRec := httptest.NewRecorder()
reqHealth, _ := http.NewRequest("GET", "/healthz", nil)
srv.mux.ServeHTTP(reqRec, reqHealth)
if reqRec.Code != http.StatusOK {
t.Errorf("expected healthz status 200, got %d", reqRec.Code)
}
reqRecOpenAPI := httptest.NewRecorder()
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
if reqRecOpenAPI.Code != http.StatusOK {
t.Errorf("expected OpenAPI status 200, got %d", reqRecOpenAPI.Code)
}
if !strings.Contains(reqRecOpenAPI.Body.String(), "getTestPost") {
t.Error("OpenAPI spec did not render registered tool definitions")
}
reqRecPayable := httptest.NewRecorder()
reqPayable, _ := http.NewRequest("POST", "/v1/getTestPost", strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRecPayable, reqPayable)
if reqRecPayable.Code != http.StatusPaymentRequired {
t.Errorf("expected status 402 (Payment Required), got %d", reqRecPayable.Code)
}
payHeader := reqRecPayable.Header().Get("PAYMENT-REQUIRED")
if payHeader == "" {
t.Error("missing PAYMENT-REQUIRED response header on 402 challenge")
}
}
func TestX402Server_MCPBoundaries(t *testing.T) {
ctx := context.Background()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{
Name: "getTestPost",
Description: "Gated test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("failed to connect server session: %v", err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("failed to connect client session: %v", err)
}
defer clientSession.Close()
toolsResponse, err := clientSession.ListTools(ctx, nil)
if err != nil {
t.Fatalf("failed to list tools over MCP: %v", err)
}
found := false
for _, tool := range toolsResponse.Tools {
if tool.Name == "getTestPost" {
found = true
break
}
}
if !found {
t.Error("expected tool 'getTestPost' to be discovered by the MCP client, but it was missing")
}
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{
Name: "getTestPost",
Arguments: json.RawMessage(`{}`),
})
if err != nil {
t.Errorf("Unexpected protocol-level error: %v", err)
} else {
if !result.IsError {
t.Error("expected CallTool to return a payment challenge, but it returned a successful, free result")
} else {
t.Logf("Intercepted expected application-level payment challenge.")
t.Logf("Content: %v", result.Content)
t.Logf("Meta fields: %v", result.Meta)
}
}
}
func setupMockFacilitator() *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "GET" && r.URL.Path == "/supported":
_, _ = w.Write([]byte(`{
"kinds": [
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:8453"
}
]
}`))
case r.Method == "POST" && r.URL.Path == "/verify":
_, _ = w.Write([]byte(`{
"isValid": true,
"invalidReason": ""
}`))
case r.Method == "POST" && r.URL.Path == "/settle":
_, _ = w.Write([]byte(`{
"success": true,
"transaction": "0x1234abcd",
"network": "eip155:8453",
"payer": "0xabcd1234"
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
return s
}