Files
bootnode/api/tests/test_chain_config.py
zeekayandHanzo Dev dee76a9e38 fix(config): list settings arrive comma-separated, as they are written
Settings() raised SettingsError constructing ALLOWED_ORIGINS. The field is
declared list[str] and documented "comma-separated in env", but
pydantic-settings decodes complex types from the environment as JSON before
any validator runs, so the plain `a,b,c` that every Deployment manifest, .env
and compose file writes was fatal — not a bad value, an unconstructable
Settings.

That made every route depending on it 500: require_admin builds an IAMClient,
which calls get_settings(). The lux.cloud fleet overview returned HTTP 500 the
moment the admin gate shipped, and the failure was in the gate, nowhere near
the collector it guarded.

NoDecode hands the raw string to one validator that splits it, for both list
settings. One variable, one format, decided in one place.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:35:59 -07:00

367 lines
12 KiB
Python

"""Tests for chain.yaml config parsing and CRD generation."""
import pytest
from bootnode.core.chains.chain_config import ChainConfig, load_chain_config
from bootnode.api.launch import generate_manifests, register_chain_networks
from bootnode.core.chains.fleet_models import CHAIN_NETWORKS
VALID_CHAIN_YAML = """
version: "1"
chain:
name: "Test Chain"
slug: test
type: l1
vm: evm
networks:
devnet:
networkId: 3
chainId: 99999
validators: 3
token:
name: "Test Token"
symbol: "TST"
decimals: 18
brand:
displayName: "Test Chain"
domains:
explorer: "explorer.test.network"
rpc: "api.test.network"
services:
node:
enabled: true
indexer:
enabled: true
explorer:
enabled: true
gateway:
enabled: true
exchange:
enabled: false
wallet:
enabled: false
faucet:
enabled: true
dripAmount: "1000000000000000000"
rateLimit: "1/hour"
deploy:
platform: hanzo
namespace: "test-{network}"
ingressClass: hanzo
"""
PARS_CHAIN_YAML = """
version: "1"
chain:
name: "Pars Network"
slug: pars
type: l1
vm: pars
vmPlugins:
- name: sessionvm
vmId: "speKUgLBX6WRD5cfGeEfLa43LxTXUBckvtv4td6F3eTXvRP48"
dbType: zapdb
compression: zstd
networks:
mainnet:
networkId: 494949
chainId: 494949
validators: 5
rpcUrl: "https://api.pars.network/v1/bc/C/rpc"
snapshotInterval: 3600
testnet:
networkId: 494950
chainId: 494950
validators: 3
sybilProtection: false
snapshotInterval: 1800
devnet:
networkId: 494951
chainId: 494951
validators: 3
sybilProtection: false
token:
name: "Pars"
symbol: "PARS"
decimals: 18
brand:
displayName: "Pars Network"
primaryColor: "#F59E0B"
domains:
explorer: "explorer.pars.network"
rpc: "api.pars.network"
services:
node:
enabled: true
image: "ghcr.io/hanzoai/parsd"
storageSize: "100Gi"
storageClass: do-block-storage
stakingKms:
hostApi: "http://kms.lux-system.svc.cluster.local/api"
projectSlug: pars-infra
envSlug: mainnet
secretsPath: /staking
indexer:
enabled: true
traceEnabled: true
contractVerification: true
pollInterval: 2
explorer:
enabled: true
gateway:
enabled: true
rateLimitRps: 100
rateLimitBurst: 200
exchange:
enabled: true
image: "ghcr.io/luxfi/exchange:main"
brandPackage: "@parsdao/brand"
faucet:
enabled: true
dripAmount: "1000000000000000000"
rateLimit: "1/hour"
deploy:
platform: hanzo
namespace: "pars-{network}"
registry: "ghcr.io/hanzoai"
ingressClass: hanzo
precompiles:
- name: mldsaVerify
blockTimestamp: 0
- name: mlkemConfig
blockTimestamp: 0
- name: blake3Config
blockTimestamp: 0
"""
class TestLoadChainConfig:
def test_load_valid(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
assert cfg.chain.name == "Test Chain"
assert cfg.chain.slug == "test"
assert cfg.chain.type == "l1"
assert cfg.token.symbol == "TST"
def test_load_defaults(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
assert cfg.chain.sequencer == "lux"
assert cfg.chain.db_type == "zapdb"
assert cfg.chain.compression == "zstd"
assert cfg.deploy.ingress_class == "hanzo"
def test_load_pars(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
assert cfg.chain.slug == "pars"
assert cfg.chain.vm == "pars"
assert len(cfg.networks) == 3
assert cfg.networks["mainnet"].chain_id == 494949
assert cfg.networks["testnet"].chain_id == 494950
assert cfg.networks["devnet"].chain_id == 494951
assert len(cfg.precompiles) == 3
assert cfg.services.node.staking_kms is not None
assert cfg.services.node.staking_kms.project_slug == "pars-infra"
def test_load_invalid_yaml(self):
with pytest.raises(ValueError, match="Invalid YAML"):
load_chain_config("not: [valid: yaml: {{")
def test_load_non_mapping(self):
with pytest.raises(ValueError, match="must be a YAML mapping"):
load_chain_config("- just\n- a\n- list\n")
def test_namespace_for(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
assert cfg.namespace_for("devnet") == "test-devnet"
assert cfg.namespace_for("mainnet") == "test-mainnet"
def test_namespace_for_pars(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
assert cfg.namespace_for("mainnet") == "pars-mainnet"
assert cfg.namespace_for("testnet") == "pars-testnet"
assert cfg.namespace_for("devnet") == "pars-devnet"
class TestValidate:
def test_valid_config(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
errors = cfg.validate_config()
assert errors == []
def test_nginx_rejected(self):
yaml_str = VALID_CHAIN_YAML.replace("ingressClass: hanzo", "ingressClass: nginx")
cfg = load_chain_config(yaml_str)
errors = cfg.validate_config()
assert any("nginx" in e for e in errors)
def test_caddy_rejected(self):
yaml_str = VALID_CHAIN_YAML.replace("ingressClass: hanzo", "ingressClass: caddy")
cfg = load_chain_config(yaml_str)
errors = cfg.validate_config()
assert any("caddy" in e for e in errors)
def test_bad_chain_type(self):
yaml_str = VALID_CHAIN_YAML.replace("type: l1", "type: l4")
with pytest.raises(Exception):
load_chain_config(yaml_str)
class TestGenerateManifests:
def test_generate_devnet(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
assert result.network == "devnet"
assert result.namespace == "test-devnet"
# namespace + node + indexer + explorer + gateway + faucet = 6
assert len(result.manifests) == 6
def test_generate_kinds(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
kinds = {m.kind for m in result.manifests}
assert "Namespace" in kinds
assert "LuxNetwork" in kinds
assert "LuxIndexer" in kinds
assert "Deployment" in kinds # explorer is now a Deployment, not a CRD
assert "LuxGateway" in kinds
def test_luxnetwork_content(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "kind: LuxNetwork" in net_manifest.yaml_content
assert "networkId: 3" in net_manifest.yaml_content
assert "validators: 3" in net_manifest.yaml_content
def test_luxindexer_content(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
idx_manifest = next(m for m in result.manifests if m.kind == "LuxIndexer")
assert "kind: LuxIndexer" in idx_manifest.yaml_content
assert "chainId: 99999" in idx_manifest.yaml_content
def test_explorer_content(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
exp_manifest = next(m for m in result.manifests if m.name == "explorer.yaml")
assert "ghcr.io/luxfi/explorer:latest" in exp_manifest.yaml_content
assert "explorer.test.network" in exp_manifest.yaml_content
assert "ingressClassName: hanzo" in exp_manifest.yaml_content
assert "containerPort: 8090" in exp_manifest.yaml_content
assert 'COIN_SYMBOL' in exp_manifest.yaml_content
assert 'CHAIN_NAME' in exp_manifest.yaml_content
assert 'RPC_ENDPOINT' in exp_manifest.yaml_content
def test_faucet_on_devnet(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
faucet = [m for m in result.manifests if m.name == "faucet.yaml"]
assert len(faucet) == 1
assert "DRIP_AMOUNT" in faucet[0].yaml_content
def test_no_faucet_on_mainnet(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "mainnet")
faucet = [m for m in result.manifests if m.name == "faucet.yaml"]
assert len(faucet) == 0
def test_exchange_disabled(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
exchange = [m for m in result.manifests if m.name == "exchange.yaml"]
assert len(exchange) == 0
def test_exchange_enabled(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
exchange = [m for m in result.manifests if m.name == "exchange.yaml"]
assert len(exchange) == 1
assert "@parsdao/brand" in exchange[0].yaml_content
def test_precompiles(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "mldsaVerify" in net_manifest.yaml_content
assert "mlkemConfig" in net_manifest.yaml_content
assert "blake3Config" in net_manifest.yaml_content
def test_kms_staking(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "mainnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "pars-infra" in net_manifest.yaml_content
assert "kms:" in net_manifest.yaml_content
def test_bad_network(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
with pytest.raises(ValueError, match="not defined"):
generate_manifests(cfg, "nonexistent")
def test_snapshot_schedule(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "mainnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "snapshotSchedule:" in net_manifest.yaml_content
assert "intervalSeconds: 3600" in net_manifest.yaml_content
def test_sybil_protection_disabled(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
result = generate_manifests(cfg, "devnet")
net_manifest = next(m for m in result.manifests if m.kind == "LuxNetwork")
assert "sybilProtectionEnabled: false" in net_manifest.yaml_content
class TestRegisterChainNetworks:
def test_register(self):
cfg = load_chain_config(VALID_CHAIN_YAML)
register_chain_networks(cfg)
assert "test" in CHAIN_NETWORKS
assert "devnet" in CHAIN_NETWORKS["test"]
assert CHAIN_NETWORKS["test"]["devnet"].chain_id == 99999
# Cleanup
del CHAIN_NETWORKS["test"]
def test_register_pars(self):
cfg = load_chain_config(PARS_CHAIN_YAML)
# Save original pars config
original = CHAIN_NETWORKS.get("pars", {}).copy()
register_chain_networks(cfg)
assert CHAIN_NETWORKS["pars"]["mainnet"].chain_id == 494949
assert CHAIN_NETWORKS["pars"]["testnet"].chain_id == 494950
assert CHAIN_NETWORKS["pars"]["devnet"].chain_id == 494951
# Restore
CHAIN_NETWORKS["pars"] = original
class TestListSettingsFromEnv:
"""ALLOWED_ORIGINS is written comma-separated everywhere (Deployment env,
.env, compose). pydantic-settings decodes list[str] from env as JSON, so
that value made Settings() raise SettingsError — which surfaced as HTTP 500
on every admin-gated route, including the lux.cloud fleet overview."""
def test_comma_separated_origins_parse(self, monkeypatch):
from bootnode.config import Settings
monkeypatch.setenv(
"ALLOWED_ORIGINS",
"https://lux.cloud,https://www.lux.cloud,https://cloud.lux.network",
)
s = Settings()
assert s.allowed_origins == [
"https://lux.cloud", "https://www.lux.cloud", "https://cloud.lux.network",
]
def test_comma_separated_orgs_parse(self, monkeypatch):
from bootnode.config import Settings
monkeypatch.setenv("ALLOWED_ORGS", "lux, zoo ,hanzo")
assert Settings().allowed_orgs == ["lux", "zoo", "hanzo"]
def test_unset_keeps_default(self, monkeypatch):
from bootnode.config import Settings
monkeypatch.delenv("ALLOWED_ORIGINS", raising=False)
assert Settings().allowed_origins == []