Files
explorer/registry_test.go
zeekayandHanzo Dev dbb11a2593 chain routes: /v1/bc -> /v1/chain
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.

Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:17:18 -07:00

499 lines
14 KiB
Go

package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestRegistryAddAndList(t *testing.T) {
r := NewChainRegistry()
err := r.Add(ChainConfig{
Slug: "cchain",
Name: "C-Chain",
RPC: "http://localhost:9650/v1/chain/C/rpc",
Type: "evm",
Source: "config",
})
if err != nil {
t.Fatal(err)
}
if r.Count() != 1 {
t.Fatalf("expected 1 chain, got %d", r.Count())
}
chains := r.List()
if chains[0].Slug != "cchain" {
t.Fatalf("expected slug cchain, got %s", chains[0].Slug)
}
}
func TestRegistryInvalidSlug(t *testing.T) {
r := NewChainRegistry()
err := r.Add(ChainConfig{Slug: "BAD SLUG", RPC: "http://x"})
if err == nil {
t.Fatal("expected error for invalid slug")
}
err = r.Add(ChainConfig{Slug: "../escape", RPC: "http://x"})
if err == nil {
t.Fatal("expected error for path traversal slug")
}
}
func TestRegistryMissingRPC(t *testing.T) {
r := NewChainRegistry()
err := r.Add(ChainConfig{Slug: "test", Name: "test"})
if err == nil {
t.Fatal("expected error for missing RPC")
}
}
func TestRegistryRemove(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "test", RPC: "http://x", Source: "admin"})
if !r.Remove("test") {
t.Fatal("expected Remove to return true")
}
if r.Count() != 0 {
t.Fatal("expected 0 chains after remove")
}
if r.Remove("nonexistent") {
t.Fatal("expected Remove to return false for nonexistent")
}
}
func TestRegistryUpdate(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "old", RPC: "http://old", Source: "config"})
err := r.Update("test", ChainConfig{Name: "new", RPC: "http://new"})
if err != nil {
t.Fatal(err)
}
cfg, ok := r.Get("test")
if !ok {
t.Fatal("chain not found after update")
}
if cfg.Name != "new" {
t.Fatalf("expected name=new, got %s", cfg.Name)
}
if cfg.RPC != "http://new" {
t.Fatalf("expected rpc=http://new, got %s", cfg.RPC)
}
}
func TestRegistryUpdateNotFound(t *testing.T) {
r := NewChainRegistry()
err := r.Update("nonexistent", ChainConfig{Name: "x"})
if err == nil {
t.Fatal("expected error for nonexistent chain")
}
}
func TestRegistryMDNSPriority(t *testing.T) {
r := NewChainRegistry()
// Config source takes precedence
r.Add(ChainConfig{Slug: "cchain", Name: "C-Chain", RPC: "http://config", Source: "config"})
// mDNS should not override
err := r.Add(ChainConfig{Slug: "cchain", Name: "C-Chain", RPC: "http://mdns", Source: "mdns"})
if err == nil {
t.Fatal("expected error when mdns tries to override config")
}
cfg, _ := r.Get("cchain")
if cfg.RPC != "http://config" {
t.Fatalf("expected config RPC to persist, got %s", cfg.RPC)
}
}
func TestRegistryDefaultType(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "test", RPC: "http://x", Source: "admin"})
cfg, _ := r.Get("test")
if cfg.Type != "evm" {
t.Fatalf("expected default type=evm, got %s", cfg.Type)
}
}
func TestRegistryLoadFromEnv(t *testing.T) {
r := NewChainRegistry()
r.LoadFromEnv("cchain:http://localhost:9650/v1/chain/C/rpc,zoo:http://localhost:9650/v1/chain/Zoo/rpc")
if r.Count() != 2 {
t.Fatalf("expected 2 chains, got %d", r.Count())
}
}
func TestRegistryLoadFromFile(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "chains.yaml")
os.WriteFile(f, []byte(`chains:
- slug: cchain
name: C-Chain
chain_id: 96369
rpc: http://localhost:9650/v1/chain/C/rpc
type: evm
default: true
- slug: zoo
name: Zoo
chain_id: 200200
rpc: http://localhost:9650/v1/chain/Zoo/rpc
type: evm
`), 0644)
r := NewChainRegistry()
err := r.LoadFromFile(f)
if err != nil {
t.Fatal(err)
}
if r.Count() != 2 {
t.Fatalf("expected 2 chains, got %d", r.Count())
}
cfg, ok := r.Get("cchain")
if !ok {
t.Fatal("cchain not found")
}
if cfg.ChainID != 96369 {
t.Fatalf("expected chain_id=96369, got %d", cfg.ChainID)
}
if cfg.Source != "config" {
t.Fatalf("expected source=config, got %s", cfg.Source)
}
}
func TestHandleList(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "Test", RPC: "http://x", Source: "admin"})
req := httptest.NewRequest("GET", "/v1/explorer/admin/chains", nil)
w := httptest.NewRecorder()
r.HandleList(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]any
json.NewDecoder(w.Body).Decode(&body)
if body["count"].(float64) != 1 {
t.Fatalf("expected count=1, got %v", body["count"])
}
}
func TestHandleAddAndRemove(t *testing.T) {
r := NewChainRegistry()
// Add
payload := `{"slug":"cchain","name":"C-Chain","rpc":"http://localhost:9650/v1/chain/C/rpc","type":"evm"}`
req := httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 201 {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if r.Count() != 1 {
t.Fatalf("expected 1 chain after add")
}
// Duplicate should conflict
req = httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 409 {
t.Fatalf("expected 409 for duplicate, got %d", w.Code)
}
// Remove
req = httptest.NewRequest("DELETE", "/v1/explorer/admin/chains/cchain", nil)
req.SetPathValue("slug", "cchain")
w = httptest.NewRecorder()
r.HandleRemove(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if r.Count() != 0 {
t.Fatal("expected 0 chains after remove")
}
}
func TestHandleUpdate(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "Old", RPC: "http://old", Source: "config"})
payload := `{"name":"New","rpc":"http://new"}`
req := httptest.NewRequest("PUT", "/v1/explorer/admin/chains/test", bytes.NewBufferString(payload))
req.SetPathValue("slug", "test")
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.HandleUpdate(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var cfg ChainConfig
json.NewDecoder(w.Body).Decode(&cfg)
if cfg.Name != "New" {
t.Fatalf("expected name=New, got %s", cfg.Name)
}
}
func TestHandleUpdateNotFound(t *testing.T) {
r := NewChainRegistry()
payload := `{"name":"x"}`
req := httptest.NewRequest("PUT", "/v1/explorer/admin/chains/nonexistent", bytes.NewBufferString(payload))
req.SetPathValue("slug", "nonexistent")
w := httptest.NewRecorder()
r.HandleUpdate(w, req)
if w.Code != 404 {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestHandleRemoveNotFound(t *testing.T) {
r := NewChainRegistry()
req := httptest.NewRequest("DELETE", "/v1/explorer/admin/chains/nonexistent", nil)
req.SetPathValue("slug", "nonexistent")
w := httptest.NewRecorder()
r.HandleRemove(w, req)
if w.Code != 404 {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestHandleAddInvalid(t *testing.T) {
r := NewChainRegistry()
// Bad JSON
req := httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString("not json"))
w := httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 400 {
t.Fatalf("expected 400 for bad json, got %d", w.Code)
}
// Missing slug
req = httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString(`{"rpc":"http://x"}`))
w = httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 409 {
t.Fatalf("expected 409 for missing slug, got %d", w.Code)
}
}
func TestEnvHelpers(t *testing.T) {
if env("NONEXISTENT_KEY_12345", "fallback") != "fallback" {
t.Fatal("env fallback failed")
}
if envBool("NONEXISTENT_KEY_12345", true) != true {
t.Fatal("envBool fallback failed")
}
}
// TestHTTPIntegration uses a real mux + httptest server to verify routing.
func TestHTTPIntegration(t *testing.T) {
registry := NewChainRegistry()
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/explorer/admin/chains", registry.HandleList)
mux.HandleFunc("POST /v1/explorer/admin/chains", registry.HandleAdd)
mux.HandleFunc("PUT /v1/explorer/admin/chains/{slug}", registry.HandleUpdate)
mux.HandleFunc("DELETE /v1/explorer/admin/chains/{slug}", registry.HandleRemove)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "chains": registry.Count()})
})
ts := httptest.NewServer(mux)
defer ts.Close()
client := ts.Client()
// Health
resp, err := client.Get(ts.URL + "/health")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("health: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// List (empty)
resp, err = client.Get(ts.URL + "/v1/explorer/admin/chains")
if err != nil {
t.Fatal(err)
}
var listBody map[string]any
json.NewDecoder(resp.Body).Decode(&listBody)
resp.Body.Close()
if listBody["count"].(float64) != 0 {
t.Fatalf("expected 0 chains initially")
}
// Add
addPayload := `{"slug":"cchain","name":"C-Chain","chain_id":96369,"rpc":"http://localhost:9650/v1/chain/C/rpc","type":"evm","default":true}`
resp, err = client.Post(ts.URL+"/v1/explorer/admin/chains", "application/json", bytes.NewBufferString(addPayload))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 201 {
t.Fatalf("add: expected 201, got %d", resp.StatusCode)
}
resp.Body.Close()
// List (1 chain)
resp, err = client.Get(ts.URL + "/v1/explorer/admin/chains")
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&listBody)
resp.Body.Close()
if listBody["count"].(float64) != 1 {
t.Fatalf("expected 1 chain after add")
}
// Update
updatePayload := `{"name":"Lux C-Chain","rpc":"http://new:9650/v1/chain/C/rpc"}`
updateReq, _ := http.NewRequest("PUT", ts.URL+"/v1/explorer/admin/chains/cchain", bytes.NewBufferString(updatePayload))
updateReq.Header.Set("Content-Type", "application/json")
resp, err = client.Do(updateReq)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("update: expected 200, got %d", resp.StatusCode)
}
var updated ChainConfig
json.NewDecoder(resp.Body).Decode(&updated)
resp.Body.Close()
if updated.Name != "Lux C-Chain" {
t.Fatalf("expected updated name, got %s", updated.Name)
}
// Delete
delReq, _ := http.NewRequest("DELETE", ts.URL+"/v1/explorer/admin/chains/cchain", nil)
resp, err = client.Do(delReq)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("delete: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Verify empty
resp, _ = client.Get(ts.URL + "/v1/explorer/admin/chains")
json.NewDecoder(resp.Body).Decode(&listBody)
resp.Body.Close()
if listBody["count"].(float64) != 0 {
t.Fatalf("expected 0 chains after delete")
}
}
// TestChainEnabledGatesRegistration pins what `enabled` means. It meant nothing
// before: the field was declared on ChainConfig and read nowhere, so a chain
// switched off in config was registered, indexed and polled exactly like one
// switched on. Each case below fails independently if the check is removed or
// if the absent-means-on default is dropped.
func TestChainEnabledGatesRegistration(t *testing.T) {
on, off := true, false
base := ChainConfig{RPC: "http://localhost:9650/v1/chain/C/rpc", Type: "evm", Source: "config"}
for _, tc := range []struct {
name string
enabled *bool
register bool
}{
{"explicit false does not register", &off, false},
{"explicit true registers", &on, true},
// The reason Enabled is a pointer. Every chains.yaml written while the
// field was inert says `enabled: true` by convention, but a plain bool
// would read a config that simply omits the key as "off" and drop the
// chain silently — no error, no log, just a missing chain.
{"absent registers", nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
r := NewChainRegistry()
c := base
c.Slug, c.Enabled = "cchain", tc.enabled
if err := r.Add(c); err != nil {
t.Fatalf("Add: %v", err)
}
if got := r.Count() == 1; got != tc.register {
t.Fatalf("registered=%v, want %v", got, tc.register)
}
if _, ok := r.Get("cchain"); ok != tc.register {
t.Fatalf("Get ok=%v, want %v", ok, tc.register)
}
})
}
}
// TestDisabledChainNeverStartsIndexer is the failure this fixes, stated as the
// symptom: a disabled chain must not reach the supervisor, because reaching it
// is what spawned the indexer goroutine that polled a nonexistent RPC forever.
func TestDisabledChainNeverStartsIndexer(t *testing.T) {
off := false
r := NewChainRegistry()
r.AttachSupervisor(nil) // a real supervisor would spawn goroutines; nil panics if reached
if err := r.Add(ChainConfig{
Slug: "spc", RPC: "http://localhost:9650/v1/chain/spc/rpc", Type: "evm",
Source: "config", Enabled: &off,
}); err != nil {
t.Fatalf("Add: %v", err)
}
if r.Count() != 0 {
t.Fatalf("disabled chain reached the registry: count=%d", r.Count())
}
if len(r.List()) != 0 {
t.Fatalf("disabled chain reached the SPA chain list: %v", r.List())
}
}
// TestLoadConfigKeepsEnabled proves the YAML actually decodes into the pointer —
// a wrong tag would leave it nil and silently re-enable every disabled chain.
func TestLoadConfigKeepsEnabled(t *testing.T) {
p := filepath.Join(t.TempDir(), "chains.yaml")
if err := os.WriteFile(p, []byte(
"chains:\n"+
" - slug: cchain\n rpc: http://x/rpc\n enabled: true\n"+
" - slug: spc\n rpc: http://x/rpc\n enabled: false\n"+
" - slug: osage\n rpc: http://x/rpc\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(p)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{"cchain": true, "spc": false, "osage": true}
for _, c := range cfg.Chains {
if c.On() != want[c.Slug] {
t.Errorf("%s: On()=%v, want %v", c.Slug, c.On(), want[c.Slug])
}
}
}