package edgecmd import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gopkg.in/yaml.v3" "iop/apps/edge/internal/configrefresh" "iop/packages/go/config" ) const minimalEdgeYAML = "server:\n listen: \"0.0.0.0:9090\"\n" func writeMinimalEdgeYAML(t *testing.T, path string) { t.Helper() if err := os.WriteFile(path, []byte(minimalEdgeYAML), 0o600); err != nil { t.Fatalf("write %s: %v", path, err) } } func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") writeMinimalEdgeYAML(t, cfgPath) root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"env", "--config", cfgPath}) if err := root.Execute(); err != nil { t.Fatalf("execute: %v\n%s", err, out.String()) } got := out.String() if !strings.Contains(got, "advertise_host: ") || !strings.Contains(got, " (auto)") { t.Errorf("expected advertise_host with (auto) source, got:\n%s", got) } expectedArtifact := "artifact_base_url: " + defaultArtifactBaseURL(resolveAdvertiseHost("").Host) + " (derived)" if !strings.Contains(got, expectedArtifact) { t.Errorf("expected derived artifact_base_url %q, got:\n%s", expectedArtifact, got) } expectedBootstrap := "node_bootstrap: " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) if !strings.Contains(got, expectedBootstrap) { t.Errorf("expected node_bootstrap %q, got:\n%s", expectedBootstrap, got) } if !strings.Contains(got, "openai_url: (disabled)") { t.Errorf("expected openai_url (disabled), got:\n%s", got) } if !strings.Contains(got, "configured_nodes: 0 (none)") { t.Errorf("expected configured_nodes 0 (none), got:\n%s", got) } if !strings.Contains(got, "connected_nodes: offline") { t.Errorf("expected connected_nodes offline placeholder, got:\n%s", got) } } func TestResolveEdgeLogPathFallsBackToBinaryDirLogsEdgeLog(t *testing.T) { got, err := ResolveEdgeLogPath("") if err != nil { t.Fatalf("ResolveEdgeLogPath: %v", err) } want := filepath.Join(binaryDir(), "logs", "edge.log") if got != want { t.Fatalf("ResolveEdgeLogPath empty: got %q, want %q", got, want) } } func TestResolveEdgeLogPathRespectsExplicitPath(t *testing.T) { explicit := filepath.Join(t.TempDir(), "custom.log") got, err := ResolveEdgeLogPath(explicit) if err != nil { t.Fatalf("ResolveEdgeLogPath: %v", err) } if got != explicit { t.Fatalf("ResolveEdgeLogPath explicit: got %q, want %q", got, explicit) } } func TestResolveAdvertiseHostPrefersExplicit(t *testing.T) { got := resolveAdvertiseHost("explicit.example.test") if got.Host != "explicit.example.test" || got.Source != "explicit" { t.Fatalf("explicit advertise: got %+v", got) } } func TestResolveAdvertiseHostFallsBackToAuto(t *testing.T) { got := resolveAdvertiseHost("") if got.Host == "" { t.Fatal("auto advertise host empty") } if got.Source != "auto" { t.Fatalf("expected auto source, got %q", got.Source) } } func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) { if got := addressFor("10.0.0.1", "0.0.0.0:9090"); got != "10.0.0.1:9090" { t.Errorf("addressFor: got %q", got) } if got := addressFor("host", ""); got != "" { t.Errorf("addressFor empty listen: got %q", got) } } func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) { origGen := tokenGenerator defer func() { tokenGenerator = origGen }() tokenGenerator = func() string { return "mocked-token-1234" } dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") writeMinimalEdgeYAML(t, cfgPath) root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--alias", "alias-a"}) if err := root.Execute(); err != nil { t.Fatalf("execute register node-a: %v\n%s", err, out.String()) } output := out.String() expectedCmd := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s mocked-token-1234" if strings.TrimSpace(output) != expectedCmd { t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, output) } data, err := os.ReadFile(cfgPath) if err != nil { t.Fatalf("read persisted config: %v", err) } var cfg config.EdgeConfig if err := yaml.Unmarshal(data, &cfg); err != nil { t.Fatalf("unmarshal config: %v", err) } if len(cfg.Nodes) != 1 { t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) } if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Alias != "alias-a" || cfg.Nodes[0].Token != "mocked-token-1234" { t.Fatalf("unexpected node state in YAML: %+v", cfg.Nodes[0]) } root = NewRoot(Options{}) out.Reset() root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "explicit-token-abc", "--adapter", "ollama", "--ollama-base-url", "http://ollama-host:11434"}) if err := root.Execute(); err != nil { t.Fatalf("execute update node-a: %v\n%s", err, out.String()) } output = out.String() expectedCmd2 := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s explicit-token-abc" if strings.TrimSpace(output) != expectedCmd2 { t.Errorf("expected only bootstrap command %q, got %q", expectedCmd2, output) } data, err = os.ReadFile(cfgPath) if err != nil { t.Fatalf("read persisted config: %v", err) } if err := yaml.Unmarshal(data, &cfg); err != nil { t.Fatalf("unmarshal config: %v", err) } if len(cfg.Nodes) != 1 { t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) } if cfg.Nodes[0].Token != "explicit-token-abc" || !cfg.Nodes[0].Adapters.Ollama.Enabled || cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://ollama-host:11434" { t.Fatalf("unexpected node state after explicit token update: %+v", cfg.Nodes[0]) } } func TestNodeRegisterOutputsPowerShellBootstrapForWindowsTarget(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") artifactDir := filepath.Join(dir, "artifacts") yamlStr := "server:\n listen: \"0.0.0.0:9090\"\nbootstrap:\n artifact_dir: \"" + strings.ReplaceAll(artifactDir, "\\", "\\\\") + "\"\n" if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil { t.Fatalf("write config: %v", err) } root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-win", "--target", "windows-amd64", "--token", "win-token-123"}) if err := root.Execute(); err != nil { t.Fatalf("execute register node-win: %v\n%s", err, out.String()) } expectedCmd := "Invoke-RestMethod " + bootstrapPowerShellScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), "windows-amd64") + " | Invoke-Expression; Start-IopNode 'win-token-123'" if strings.TrimSpace(out.String()) != expectedCmd { t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, out.String()) } if _, err := os.Stat(filepath.Join(artifactDir, "bootstrap", "node-windows-amd64.ps1")); err != nil { t.Fatalf("expected powershell bootstrap script to be refreshed: %v", err) } } func TestNodesListPrintsConfiguredProviders(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") yaml := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-gpu-01" alias: "gpu-node" token: "token-1" providers: - id: "vllm-gpu" type: "vllm" category: "api" adapter: "vllm-gpu" models: - "nvidia/Qwen3.6-35B-A3B-NVFP4" capacity: 4 lifecycle_capabilities: - "list_models" - "load_model" ` if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { t.Fatalf("write %s: %v", cfgPath, err) } root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "nodes", "list"}) if err := root.Execute(); err != nil { t.Fatalf("execute nodes list: %v\n%s", err, out.String()) } got := out.String() for _, want := range []string{ "node-gpu-01", "providers: vllm-gpu", "type=vllm/api", "models=nvidia/Qwen3.6-35B-A3B-NVFP4", "capacity=4", "lifecycle=list_models,load_model", } { if !strings.Contains(got, want) { t.Errorf("nodes list output missing %q:\n%s", want, got) } } } func TestPatchYAMLPreservesArtifactBootstrapField(t *testing.T) { origYAML := `server: listen: "0.0.0.0:9090" bootstrap: artifact_base_url: "http://old.example.com/artifacts" ` cfg := &config.EdgeConfig{ Bootstrap: config.EdgeBootstrapConf{ ArtifactBaseURL: "http://example.com/artifacts", }, } patched, err := patchYAML([]byte(origYAML), cfg) if err != nil { t.Fatalf("patchYAML: %v", err) } var doc struct { Server struct { Listen string `yaml:"listen"` } `yaml:"server"` Bootstrap struct { ArtifactBaseURL string `yaml:"artifact_base_url"` } `yaml:"bootstrap"` } if err := yaml.Unmarshal(patched, &doc); err != nil { t.Fatalf("unmarshal patched: %v", err) } if doc.Server.Listen != "0.0.0.0:9090" { t.Errorf("server.listen was modified: %q", doc.Server.Listen) } if doc.Bootstrap.ArtifactBaseURL != "http://example.com/artifacts" { t.Errorf("artifact_base_url = %q", doc.Bootstrap.ArtifactBaseURL) } } func TestValidateEdgeConfig_VllmLegacyEmptyEndpointRejected(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ Vllm: config.VllmConf{Enabled: true, Endpoint: ""}, }, }, }, } err := validateEdgeConfig(cfg) if err == nil { t.Fatal("expected error for enabled vllm with empty endpoint") } } func TestValidateEdgeConfig_VllmInstanceEmptyEndpointRejected(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ VllmInstances: []config.VllmInstanceConf{ {Name: "a100", Enabled: true, Endpoint: ""}, }, }, }, }, } err := validateEdgeConfig(cfg) if err == nil { t.Fatal("expected error for enabled vllm instance with empty endpoint") } } func TestValidateEdgeConfig_AllAdaptersDisabledRejected(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ Ollama: config.OllamaConf{Enabled: false}, Vllm: config.VllmConf{Enabled: false}, CLI: config.CLIConf{Enabled: false}, OllamaInstances: []config.OllamaInstanceConf{ {Name: "off", Enabled: false, BaseURL: "http://127.0.0.1:11434"}, }, VllmInstances: []config.VllmInstanceConf{ {Name: "off", Enabled: false, Endpoint: "http://10.0.0.5:8000"}, }, }, }, }, } err := validateEdgeConfig(cfg) if err == nil { t.Fatal("expected error when all adapters are disabled") } } func TestValidateEdgeConfig_OllamaInstanceEnabledAccepted(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ OllamaInstances: []config.OllamaInstanceConf{ {Name: "local", Enabled: true, BaseURL: "http://127.0.0.1:11434"}, }, }, }, }, } if err := validateEdgeConfig(cfg); err != nil { t.Fatalf("unexpected error: %v", err) } } func TestValidateEdgeConfig_OpenAICompatInstanceEnabled(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ OpenAICompatInstances: []config.OpenAICompatInstanceConf{ {Name: "lemonade", Enabled: true, Endpoint: "http://127.0.0.1:13305"}, }, }, }, }, } if err := validateEdgeConfig(cfg); err != nil { t.Fatalf("unexpected error: %v", err) } } func TestValidateEdgeConfig_OpenAICompatInstanceEmptyEndpointRejected(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ OpenAICompatInstances: []config.OpenAICompatInstanceConf{ {Name: "lemonade", Enabled: true, Endpoint: ""}, }, }, }, }, } err := validateEdgeConfig(cfg) if err == nil { t.Fatal("expected error for enabled openai_compat instance with empty endpoint") } } // TestConfigRefreshCommandPostsModeAndConfigPath verifies that the CLI refresh // command sends a valid JSON request to the admin API and prints the JSON result. func TestConfigRefreshCommandPostsModeAndConfigPath(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") writeMinimalEdgeYAML(t, cfgPath) expectedResult := configrefresh.Result{ Status: configrefresh.StatusApplied, Mode: configrefresh.ModeDryRun, Summary: "no changes detected", Changes: []configrefresh.Change{}, } var capturedReq configrefresh.Request ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(expectedResult) })) defer ts.Close() // Strip "http://" prefix for --addr flag. addr := strings.TrimPrefix(ts.URL, "http://") root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "config", "refresh", "--mode", "dry-run", "--addr", addr, "--config-path", cfgPath, }) if err := root.Execute(); err != nil { t.Fatalf("execute: %v\n%s", err, out.String()) } if capturedReq.Mode != configrefresh.ModeDryRun { t.Errorf("expected mode=%q, got %q", configrefresh.ModeDryRun, capturedReq.Mode) } if capturedReq.ConfigPath != cfgPath { t.Errorf("expected config_path=%q, got %q", cfgPath, capturedReq.ConfigPath) } if !strings.Contains(out.String(), string(configrefresh.StatusApplied)) { t.Errorf("expected status in output; got:\n%s", out.String()) } } // TestConfigRefreshCommandPrintsChangedItems verifies SDD S11 on the CLI // surface: the refresh command prints the changed node/provider/model report // fields returned by the admin API so an operator can confirm what changed. func TestConfigRefreshCommandPrintsChangedItems(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") writeMinimalEdgeYAML(t, cfgPath) result := configrefresh.Result{ Status: configrefresh.StatusApplied, Mode: configrefresh.ModeDryRun, Summary: "all changes can be applied without restart", Changes: []configrefresh.Change{}, ChangedNodes: []string{}, ChangedProviders: []string{"prov-a"}, ChangedModels: []string{"qwen3.6:35b"}, RestartRequiredPaths: []string{}, } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) })) defer ts.Close() addr := strings.TrimPrefix(ts.URL, "http://") root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "config", "refresh", "--mode", "dry-run", "--addr", addr, "--config-path", cfgPath, }) if err := root.Execute(); err != nil { t.Fatalf("execute: %v\n%s", err, out.String()) } // Decode the printed JSON back so the assertion does not depend on key order. var printed configrefresh.Result if err := json.Unmarshal(out.Bytes(), &printed); err != nil { t.Fatalf("decode printed output: %v\n%s", err, out.String()) } if len(printed.ChangedProviders) != 1 || printed.ChangedProviders[0] != "prov-a" { t.Errorf("expected changed_providers=[prov-a] in CLI output, got %v", printed.ChangedProviders) } if len(printed.ChangedModels) != 1 || printed.ChangedModels[0] != "qwen3.6:35b" { t.Errorf("expected changed_models=[qwen3.6:35b] in CLI output, got %v", printed.ChangedModels) } } // TestConfigRefreshCommandRejectsInvalidMode verifies that an invalid --mode // flag returns an error before sending any HTTP request. func TestConfigRefreshCommandRejectsInvalidMode(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") writeMinimalEdgeYAML(t, cfgPath) root := NewRoot(Options{}) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--config", cfgPath, "config", "refresh", "--mode", "invalid-mode", "--addr", "127.0.0.1:0", "--config-path", cfgPath, }) err := root.Execute() if err == nil { t.Fatalf("expected error for invalid mode, got nil\n%s", out.String()) } if !strings.Contains(err.Error(), "invalid --mode") { t.Errorf("expected 'invalid --mode' in error, got: %v", err) } } func TestValidateEdgeConfig_OpenAICompatLegacyEmptyEndpointRejected(t *testing.T) { cfg := &config.EdgeConfig{ Nodes: []config.NodeDefinition{ { ID: "node-1", Alias: "test", Token: "tok", Adapters: config.AdaptersConf{ OpenAICompat: config.OpenAICompatConf{Enabled: true, Endpoint: ""}, }, }, }, } err := validateEdgeConfig(cfg) if err == nil { t.Fatal("expected error for enabled legacy openai_compat with empty endpoint") } }