package rest import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" ) type testNetworksCatalog struct { Name string `json:"name"` DefaultChainID int `json:"defaultChainId"` ExplorerURL string `json:"explorerUrl"` TokenListURL string `json:"tokenListUrl"` GeneratedBy string `json:"generatedBy"` Chains []struct { ChainID string `json:"chainId"` ChainIDDecimal int `json:"chainIdDecimal"` ChainName string `json:"chainName"` ShortName string `json:"shortName"` RPCURLs []string `json:"rpcUrls"` BlockExplorerURL []string `json:"blockExplorerUrls"` InfoURL string `json:"infoURL"` ExplorerAPIURL string `json:"explorerApiUrl"` Testnet bool `json:"testnet"` } `json:"chains"` } type testTokenList struct { Name string `json:"name"` Keywords []string Extensions struct { DefaultChainID int `json:"defaultChainId"` ExplorerURL string `json:"explorerUrl"` NetworksConfigURL string `json:"networksConfigUrl"` } `json:"extensions"` Tokens []struct { ChainID int `json:"chainId"` Address string `json:"address"` Symbol string `json:"symbol"` Decimals int `json:"decimals"` Extensions struct { UnitOfAccount string `json:"unitOfAccount"` UnitDescription string `json:"unitDescription"` } `json:"extensions"` } `json:"tokens"` } type testCapabilitiesCatalog struct { Name string `json:"name"` GeneratedBy string `json:"generatedBy"` ChainID int `json:"chainId"` ChainName string `json:"chainName"` RPCURL string `json:"rpcUrl"` HTTP struct { SupportedMethods []string `json:"supportedMethods"` UnsupportedMethods []string `json:"unsupportedMethods"` Notes []string `json:"notes"` } `json:"http"` Tracing struct { SupportedMethods []string `json:"supportedMethods"` UnsupportedMethods []string `json:"unsupportedMethods"` Notes []string `json:"notes"` } `json:"tracing"` WalletSupport struct { WalletAddEthereumChain bool `json:"walletAddEthereumChain"` WalletWatchAsset bool `json:"walletWatchAsset"` Notes []string `json:"notes"` } `json:"walletSupport"` } func setupConfigHandler() http.Handler { server := NewServer(nil, 138) mux := http.NewServeMux() server.SetupRoutes(mux) return server.addMiddleware(mux) } func containsString(items []string, want string) bool { for _, item := range items { if item == want { return true } } return false } func TestConfigNetworksEndpointProvidesWalletMetadata(t *testing.T) { handler := setupConfigHandler() req := httptest.NewRequest(http.MethodGet, "/api/config/networks", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got == "" { t.Fatal("expected CORS header on config endpoint") } var payload testNetworksCatalog if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to parse networks payload: %v", err) } if payload.DefaultChainID != 138 { t.Fatalf("expected defaultChainId 138, got %d", payload.DefaultChainID) } if payload.ExplorerURL == "" || payload.TokenListURL == "" || payload.GeneratedBy == "" { t.Fatal("expected root metadata fields to be populated") } if len(payload.Chains) < 3 { t.Fatalf("expected multiple chain entries, got %d", len(payload.Chains)) } var foundChain138 bool for _, chain := range payload.Chains { if chain.ChainIDDecimal != 138 { continue } foundChain138 = true if chain.ShortName == "" || chain.InfoURL == "" || chain.ExplorerAPIURL == "" { t.Fatal("expected Chain 138 optional metadata to be populated") } if len(chain.RPCURLs) == 0 || len(chain.BlockExplorerURL) == 0 { t.Fatal("expected Chain 138 RPC and explorer URLs") } if chain.Testnet { t.Fatal("expected Chain 138 to be marked as mainnet") } } if !foundChain138 { t.Fatal("expected Chain 138 entry in networks catalog") } } func TestConfigTokenListEndpointProvidesOptionalMetadata(t *testing.T) { handler := setupConfigHandler() req := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload testTokenList if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to parse token list payload: %v", err) } if len(payload.Keywords) == 0 { t.Fatal("expected token list keywords") } if payload.Extensions.DefaultChainID != 138 || payload.Extensions.ExplorerURL == "" || payload.Extensions.NetworksConfigURL == "" { t.Fatal("expected root-level token list extensions") } var foundCXAUC bool var foundCUSDT bool for _, token := range payload.Tokens { switch token.Symbol { case "cXAUC": foundCXAUC = true if token.Extensions.UnitOfAccount == "" || token.Extensions.UnitDescription == "" { t.Fatal("expected cXAUC optional unit metadata") } case "cUSDT": foundCUSDT = true if token.Decimals != 6 { t.Fatalf("expected cUSDT decimals 6, got %d", token.Decimals) } } } if !foundCXAUC || !foundCUSDT { t.Fatal("expected cXAUC and cUSDT in token list") } } func TestConfigCapabilitiesEndpointProvidesRPCCapabilityMatrix(t *testing.T) { handler := setupConfigHandler() req := httptest.NewRequest(http.MethodGet, "/api/config/capabilities", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload testCapabilitiesCatalog if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to parse capabilities payload: %v", err) } if payload.ChainID != 138 || payload.ChainName == "" || payload.RPCURL == "" || payload.GeneratedBy == "" { t.Fatal("expected populated chain-level capability metadata") } if !payload.WalletSupport.WalletAddEthereumChain || !payload.WalletSupport.WalletWatchAsset { t.Fatal("expected wallet support flags to be true") } if !containsString(payload.HTTP.SupportedMethods, "eth_feeHistory") { t.Fatal("expected eth_feeHistory support to be documented") } if !containsString(payload.HTTP.SupportedMethods, "eth_maxPriorityFeePerGas") { t.Fatal("expected eth_maxPriorityFeePerGas support to be documented") } if !containsString(payload.Tracing.SupportedMethods, "trace_block") { t.Fatal("expected trace_block support to be documented") } } func TestConfigTokenListEndpointReloadsRuntimeFileWithoutRestart(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "token-list.json") first := `{"name":"Runtime Token List v1","tokens":[{"chainId":138,"address":"0x1111111111111111111111111111111111111111","symbol":"RT1","name":"Runtime One","decimals":6}]}` second := `{"name":"Runtime Token List v2","tokens":[{"chainId":138,"address":"0x2222222222222222222222222222222222222222","symbol":"RT2","name":"Runtime Two","decimals":6}]}` if err := os.WriteFile(file, []byte(first), 0o644); err != nil { t.Fatalf("failed to write initial runtime file: %v", err) } t.Setenv("CONFIG_TOKEN_LIST_JSON_PATH", file) handler := setupConfigHandler() req1 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) if w1.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w1.Code) } if got := w1.Header().Get("X-Config-Source"); got != "runtime-file" { t.Fatalf("expected runtime-file config source, got %q", got) } etag1 := w1.Header().Get("ETag") if etag1 == "" { t.Fatal("expected ETag header on runtime-backed response") } var body1 testTokenList if err := json.Unmarshal(w1.Body.Bytes(), &body1); err != nil { t.Fatalf("failed to parse runtime token list v1: %v", err) } if body1.Name != "Runtime Token List v1" { t.Fatalf("expected first runtime payload, got %q", body1.Name) } if err := os.WriteFile(file, []byte(second), 0o644); err != nil { t.Fatalf("failed to write updated runtime file: %v", err) } req2 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) if w2.Code != http.StatusOK { t.Fatalf("expected 200 after runtime update, got %d", w2.Code) } if got := w2.Header().Get("ETag"); got == "" || got == etag1 { t.Fatalf("expected changed ETag after runtime update, got %q", got) } var body2 testTokenList if err := json.Unmarshal(w2.Body.Bytes(), &body2); err != nil { t.Fatalf("failed to parse runtime token list v2: %v", err) } if body2.Name != "Runtime Token List v2" { t.Fatalf("expected updated runtime payload, got %q", body2.Name) } } func TestConfigTokenListEndpointSupportsETagRevalidation(t *testing.T) { handler := setupConfigHandler() req1 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) if w1.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w1.Code) } etag := w1.Header().Get("ETag") if etag == "" { t.Fatal("expected ETag header") } req2 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil) req2.Header.Set("If-None-Match", etag) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) if w2.Code != http.StatusNotModified { t.Fatalf("expected 304, got %d", w2.Code) } } func TestConfigEndpointsSupportOptionsPreflight(t *testing.T) { handler := setupConfigHandler() req := httptest.NewRequest(http.MethodOptions, "/api/config/token-list", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 preflight response, got %d", w.Code) } if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Fatal("expected Access-Control-Allow-Methods header") } }