mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
325 lines
8.4 KiB
Go
325 lines
8.4 KiB
Go
// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package devcmd
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestPortForApp(t *testing.T) {
|
|
tests := []struct {
|
|
portBase int
|
|
chainIndex int
|
|
want int
|
|
}{
|
|
{9650, 0, 9650},
|
|
{9650, 1, 9750},
|
|
{9650, 2, 9850},
|
|
{3001, 0, 3001},
|
|
{3001, 1, 3101},
|
|
{3001, 3, 3301},
|
|
}
|
|
for _, tt := range tests {
|
|
got := PortForApp(tt.portBase, tt.chainIndex)
|
|
if got != tt.want {
|
|
t.Errorf("PortForApp(%d, %d) = %d, want %d", tt.portBase, tt.chainIndex, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChainInstanceName(t *testing.T) {
|
|
tests := []struct {
|
|
app string
|
|
index int
|
|
want string
|
|
}{
|
|
{"luxd", 0, "luxd"},
|
|
{"luxd", 1, "luxd-1"},
|
|
{"explorer", 0, "explorer"},
|
|
{"explorer", 2, "explorer-2"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := chainInstanceName(tt.app, tt.index)
|
|
if got != tt.want {
|
|
t.Errorf("chainInstanceName(%q, %d) = %q, want %q", tt.app, tt.index, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsDigitSuffix(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
{"explorer-1", true},
|
|
{"luxd-0", true},
|
|
{"luxd-12", true},
|
|
{"explorer", false},
|
|
{"explorer-", false},
|
|
{"explorer-abc", false},
|
|
}
|
|
for _, tt := range tests {
|
|
got := isDigitSuffix(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("isDigitSuffix(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExpandPath(t *testing.T) {
|
|
home, _ := os.UserHomeDir()
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"~/.lux/dev/data", filepath.Join(home, ".lux/dev/data")},
|
|
{"/absolute/path", "/absolute/path"},
|
|
{"relative/path", "relative/path"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := expandPath(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("expandPath(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDefaultConfig(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
if cfg.Chains != 1 {
|
|
t.Errorf("default chains = %d, want 1", cfg.Chains)
|
|
}
|
|
if len(cfg.Apps) != 8 {
|
|
t.Errorf("default apps = %d, want 8", len(cfg.Apps))
|
|
}
|
|
|
|
// luxd must be first and enabled
|
|
if cfg.Apps[0].Name != "luxd" {
|
|
t.Errorf("first app = %q, want luxd", cfg.Apps[0].Name)
|
|
}
|
|
if !cfg.Apps[0].Enabled {
|
|
t.Error("luxd should be enabled by default")
|
|
}
|
|
|
|
// exchange must be disabled
|
|
exchange := findApp(cfg, "exchange")
|
|
if exchange == nil {
|
|
t.Fatal("exchange not found in default config")
|
|
}
|
|
if exchange.Enabled {
|
|
t.Error("exchange should be disabled by default")
|
|
}
|
|
}
|
|
|
|
func TestConfigSaveLoad(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test-stack.yaml")
|
|
|
|
// Override the config path for this test
|
|
origPath := stackConfigPath
|
|
stackConfigPath = path
|
|
defer func() { stackConfigPath = origPath }()
|
|
|
|
cfg := defaultConfig()
|
|
cfg.Chains = 3
|
|
|
|
if err := saveConfig(cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
loaded, err := loadConfig()
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
|
|
if loaded.Chains != 3 {
|
|
t.Errorf("loaded chains = %d, want 3", loaded.Chains)
|
|
}
|
|
if len(loaded.Apps) != len(cfg.Apps) {
|
|
t.Errorf("loaded apps = %d, want %d", len(loaded.Apps), len(cfg.Apps))
|
|
}
|
|
}
|
|
|
|
func TestConfigYAMLRoundTrip(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
data, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
var loaded StackConfig
|
|
if err := yaml.Unmarshal(data, &loaded); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if loaded.Chains != cfg.Chains {
|
|
t.Errorf("chains: got %d, want %d", loaded.Chains, cfg.Chains)
|
|
}
|
|
for i, app := range loaded.Apps {
|
|
if app.Name != cfg.Apps[i].Name {
|
|
t.Errorf("app[%d].name: got %q, want %q", i, app.Name, cfg.Apps[i].Name)
|
|
}
|
|
if app.PortBase != cfg.Apps[i].PortBase {
|
|
t.Errorf("app[%d].port_base: got %d, want %d", i, app.PortBase, cfg.Apps[i].PortBase)
|
|
}
|
|
if app.Enabled != cfg.Apps[i].Enabled {
|
|
t.Errorf("app[%d].enabled: got %v, want %v", i, app.Enabled, cfg.Apps[i].Enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPIDFileRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
name := "test-app"
|
|
pid := 12345
|
|
|
|
if err := writePID(dir, name, pid); err != nil {
|
|
t.Fatalf("writePID: %v", err)
|
|
}
|
|
|
|
got, err := readPID(dir, name)
|
|
if err != nil {
|
|
t.Fatalf("readPID: %v", err)
|
|
}
|
|
if got != pid {
|
|
t.Errorf("readPID = %d, want %d", got, pid)
|
|
}
|
|
|
|
// Remove and verify gone
|
|
removePIDFile(dir, name)
|
|
_, err = readPID(dir, name)
|
|
if err == nil {
|
|
t.Error("expected error after removePIDFile")
|
|
}
|
|
}
|
|
|
|
func TestReadPIDNonExistent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, err := readPID(dir, "nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent PID file")
|
|
}
|
|
}
|
|
|
|
func TestFindApp(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
|
|
luxd := findApp(cfg, "luxd")
|
|
if luxd == nil {
|
|
t.Fatal("luxd not found")
|
|
}
|
|
if luxd.PortBase != 9650 {
|
|
t.Errorf("luxd port_base = %d, want 9650", luxd.PortBase)
|
|
}
|
|
|
|
missing := findApp(cfg, "nonexistent")
|
|
if missing != nil {
|
|
t.Error("expected nil for missing app")
|
|
}
|
|
}
|
|
|
|
func TestPortDeconflictionMultiChain(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Chains = 3
|
|
|
|
// Verify no port collisions across all apps and chains
|
|
ports := make(map[int]string)
|
|
for _, app := range cfg.Apps {
|
|
for i := 0; i < cfg.Chains; i++ {
|
|
port := PortForApp(app.PortBase, i)
|
|
name := chainInstanceName(app.Name, i)
|
|
if existing, ok := ports[port]; ok {
|
|
t.Errorf("port collision: %d used by both %s and %s", port, existing, name)
|
|
}
|
|
ports[port] = name
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPortForAppChecked_RejectsOverflow covers the Red-#7 vector:
|
|
// large --chains values must not silently produce out-of-range TCP
|
|
// ports (which would either cause listener failures, overflow into
|
|
// privileged ranges, or wrap into negative numbers on some systems).
|
|
func TestPortForAppChecked_RejectsOverflow(t *testing.T) {
|
|
// Well within range.
|
|
if p, err := PortForAppChecked(9650, 0); err != nil || p != 9650 {
|
|
t.Fatalf("portBase 9650 chain 0: got p=%d err=%v", p, err)
|
|
}
|
|
if p, err := PortForAppChecked(9650, 4); err != nil || p != 10050 {
|
|
t.Fatalf("portBase 9650 chain 4: got p=%d err=%v", p, err)
|
|
}
|
|
|
|
// Out-of-range. With portStride=100, base 9650 + 1000 chains = 109,650 > 65535.
|
|
if _, err := PortForAppChecked(9650, 1000); err == nil {
|
|
t.Fatal("expected overflow error for portBase=9650 chainIndex=1000")
|
|
}
|
|
// Exactly at the boundary should also fail for a large stride.
|
|
if _, err := PortForAppChecked(60000, 100); err == nil {
|
|
t.Fatal("expected overflow error for portBase=60000 chainIndex=100")
|
|
}
|
|
}
|
|
|
|
// TestValidateStackConfig_RejectsInjections confirms the config loader
|
|
// refuses shapes that would let a hostile stack.yaml escape its sandbox.
|
|
// Each sub-case is a single-field delta from a known-good config.
|
|
func TestValidateStackConfig_RejectsInjections(t *testing.T) {
|
|
good := func() *StackConfig {
|
|
return &StackConfig{
|
|
Chains: 2,
|
|
Apps: []AppEntry{
|
|
{Name: "luxd", PortBase: 9650, Enabled: true},
|
|
{Name: "explorer", PortBase: 3001, Enabled: true, Binary: "ghcr.io/luxfi/explorer:local"},
|
|
},
|
|
}
|
|
}
|
|
|
|
if err := validateStackConfig(good()); err != nil {
|
|
t.Fatalf("baseline config should validate: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*StackConfig)
|
|
wantErr bool
|
|
}{
|
|
{"chains=0", func(c *StackConfig) { c.Chains = 0 }, true},
|
|
{"chains=33", func(c *StackConfig) { c.Chains = 33 }, true},
|
|
{"app name path-traversal", func(c *StackConfig) { c.Apps[0].Name = "../evil" }, true},
|
|
{"app name uppercase", func(c *StackConfig) { c.Apps[0].Name = "LuxD" }, true},
|
|
{"app name empty", func(c *StackConfig) { c.Apps[0].Name = "" }, true},
|
|
{"port base 0", func(c *StackConfig) { c.Apps[0].PortBase = 0 }, true},
|
|
{"port base 65536", func(c *StackConfig) { c.Apps[0].PortBase = 65536 }, true},
|
|
{"port overflow with --chains", func(c *StackConfig) { c.Chains = 32; c.Apps[0].PortBase = 65000 }, true},
|
|
{"binary shell injection semicolon", func(c *StackConfig) {
|
|
c.Apps[1].Binary = "ghcr.io/luxfi/explorer:local; rm -rf /"
|
|
}, true},
|
|
{"binary shell injection backtick", func(c *StackConfig) {
|
|
c.Apps[1].Binary = "ghcr.io/x:`id`"
|
|
}, true},
|
|
{"binary shell injection pipe", func(c *StackConfig) {
|
|
c.Apps[1].Binary = "foo | tee /etc/passwd"
|
|
}, true},
|
|
{"benign slashes allowed", func(c *StackConfig) {
|
|
c.Apps[1].Binary = "/usr/local/bin/explorer-0.1"
|
|
}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := good()
|
|
tc.mutate(cfg)
|
|
err := validateStackConfig(cfg)
|
|
if tc.wantErr && err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|