From 214ea12e63283fe21ef1cf92c6abcda089cc127b Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Mon, 29 Jun 2026 15:13:21 -0400 Subject: [PATCH 1/3] [quality] add unit tests for server_routes.go route registration Signed-off-by: clubanderson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/agent/server_routes_test.go | 225 ++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 pkg/agent/server_routes_test.go diff --git a/pkg/agent/server_routes_test.go b/pkg/agent/server_routes_test.go new file mode 100644 index 0000000000..040e49651c --- /dev/null +++ b/pkg/agent/server_routes_test.go @@ -0,0 +1,225 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// expectedRoutes is the full list of patterns registered by registerRoutes. +// If a new route is added to server_routes.go but not here, this test fails +// — forcing the author to verify the route is intentional. +var expectedRoutes = []string{ + "/health", + "/status", + "/clusters", + "/gpu-nodes", + "/gpu-nodes/stream", + "/nodes", + "/nodes/stream", + "/pods", + "/pods/stream", + "/events", + "/events/stream", + "/namespaces", + "/deployments", + "/replicasets", + "/statefulsets", + "/daemonsets", + "/cronjobs", + "/ingresses", + "/networkpolicies", + "/services", + "/configmaps", + "/secrets", + "/serviceaccounts", + "/jobs", + "/jobs/stream", + "/hpas", + "/pvcs", + "/pvs", + "/cluster-health", + "/roles", + "/rolebindings", + "/resourcequotas", + "/limitranges", + "/resolve-deps", + "/scale", + "/workloads/deploy", + "/workloads/delete", + "/serviceexports", + "/cilium-status", + "/jaeger-status", + "/helm/rollback", + "/helm/uninstall", + "/helm/upgrade", + "/console-cr/workloads", + "/console-cr/groups", + "/console-cr/deployments", + "/console-cr/deployments/status", + "/federation/detect", + "/federation/clusters", + "/federation/groups", + "/federation/pending-joins", + "/federation/action", + "/gitops/detect-drift", + "/gitops/sync", + "/argocd/sync", + "/gpu-health-cronjob", + "/nvidia-operators", + "/rbac/can-i", + "/rbac/permissions", + "/permissions/summary", + "/rename-context", + "/kubeconfig/preview", + "/kubeconfig/import", + "/kubeconfig/add", + "/kubeconfig/test", + "/kubeconfig/remove", + "/settings/keys", + "/settings/keys/", + "/settings", + "/settings/export", + "/settings/import", + "/providers/health", + "/provider/check", + "/predictions/ai", + "/predictions/analyze", + "/predictions/feedback", + "/predictions/stats", + "/insights/enrich", + "/insights/ai", + "/devices/alerts", + "/devices/alerts/clear", + "/devices/inventory", + "/metrics/history", + "/kagenti/agents", + "/kagenti/builds", + "/kagenti/cards", + "/kagenti/tools", + "/kagenti/summary", + "/kagent-crds/agents", + "/kagent-crds/tools", + "/kagent-crds/models", + "/kagent-crds/memories", + "/kagent-crds/summary", + "/cloud-cli-status", + "/local-cluster-tools", + "/local-clusters", + "/local-cluster-lifecycle", + "/vcluster/list", + "/vcluster/create", + "/vcluster/connect", + "/vcluster/disconnect", + "/vcluster/delete", + "/vcluster/check", + "/cancel-chat", + "/restart-backend", + "/auto-update/config", + "/auto-update/status", + "/auto-update/trigger", + "/auto-update/cancel", + "/prometheus/query", + "/metrics", + "/ws", + "/ws/exec", +} + +// TestRegisterRoutes_AllExpectedRoutesRegistered verifies that registerRoutes +// registers a handler for every expected path. We call registerRoutes on a +// real ServeMux and then issue a GET to each expected path; any path that +// returns 404 is missing from the registration. +func TestRegisterRoutes_AllExpectedRoutesRegistered(t *testing.T) { + t.Parallel() + + // Build a minimal server — handlers will likely panic if called, but + // we only need the mux pattern matching to work; we never invoke the + // handler logic. + s := &Server{ + allowedOrigins: []string{"http://localhost"}, + } + + mux := http.NewServeMux() + s.registerRoutes(mux) + + for _, route := range expectedRoutes { + t.Run(route, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, route, nil) + // ServeMux.Handler returns the handler and the matched pattern. + _, pattern := mux.Handler(req) + if pattern == "" { + t.Errorf("route %q is not registered in registerRoutes", route) + } + }) + } +} + +// TestRegisterRoutes_CatchallReturns404ForUnknownPaths verifies that the +// catch-all "/" handler returns 404 for arbitrary unknown paths. +func TestRegisterRoutes_CatchallReturns404ForUnknownPaths(t *testing.T) { + t.Parallel() + + s := &Server{ + allowedOrigins: []string{"http://localhost"}, + } + + mux := http.NewServeMux() + s.registerRoutes(mux) + + unknownPaths := []string{ + "/nonexistent", + "/api/v1/pods", + "/admin/secret", + } + + for _, path := range unknownPaths { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Origin", "http://localhost") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown path %q, got %d", path, rr.Code) + } + }) + } +} + +// TestRegisterRoutes_CatchallHandlesOPTIONS verifies that the catch-all "/" +// handler responds to OPTIONS preflight with 204 No Content. +func TestRegisterRoutes_CatchallHandlesOPTIONS(t *testing.T) { + t.Parallel() + + s := &Server{ + allowedOrigins: []string{"http://localhost"}, + } + + mux := http.NewServeMux() + s.registerRoutes(mux) + + req := httptest.NewRequest(http.MethodOptions, "/unknown-path", nil) + req.Header.Set("Origin", "http://localhost") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Errorf("expected 204 for OPTIONS on catch-all, got %d", rr.Code) + } +} + +// TestRegisterRoutes_RouteCount guards against accidentally removing routes +// during refactoring. If the number of registered routes drops below the +// expected count, the refactor likely lost a route. +func TestRegisterRoutes_RouteCount(t *testing.T) { + t.Parallel() + + // The expected count should match len(expectedRoutes). Update both when + // adding or removing routes intentionally. + const minExpectedRoutes = 100 + + if len(expectedRoutes) < minExpectedRoutes { + t.Errorf("expectedRoutes slice has only %d entries; expected at least %d — did you forget to update?", + len(expectedRoutes), minExpectedRoutes) + } +} From 67d12792499e398a77d10e4d1b309cf70e71f48c Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Mon, 29 Jun 2026 15:13:39 -0400 Subject: [PATCH 2/3] [quality] add unit tests for shell.go and shell_unix.go Tests resolveShell, shellFlag, isWindows on Unix platforms. Signed-off-by: clubanderson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/agent/shell_unix_test.go | 79 ++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 pkg/agent/shell_unix_test.go diff --git a/pkg/agent/shell_unix_test.go b/pkg/agent/shell_unix_test.go new file mode 100644 index 0000000000..2c7b8ee929 --- /dev/null +++ b/pkg/agent/shell_unix_test.go @@ -0,0 +1,79 @@ +//go:build !windows + +package agent + +import ( + "os/exec" + "strings" + "testing" +) + +// TestResolveShell_ReturnsValidPath verifies that resolveShell returns a +// non-empty path to an existing shell binary. On CI/CD environments (Linux) +// at minimum /bin/sh must be available. +func TestResolveShell_ReturnsValidPath(t *testing.T) { + t.Parallel() + + path, err := resolveShell() + if err != nil { + t.Fatalf("resolveShell() returned error: %v", err) + } + if path == "" { + t.Fatal("resolveShell() returned empty path") + } + + // The returned path should be an absolute path or resolvable via LookPath. + if _, err := exec.LookPath(path); err != nil { + t.Errorf("resolveShell() returned path %q that is not executable: %v", path, err) + } +} + +// TestResolveShell_PrefersBash verifies that when bash is available (as it +// is on virtually all CI and dev systems), resolveShell picks it over sh. +func TestResolveShell_PrefersBash(t *testing.T) { + t.Parallel() + + // Skip if bash is not installed (e.g., minimal Alpine containers). + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available on this system") + } + + path, err := resolveShell() + if err != nil { + t.Fatalf("resolveShell() returned error: %v", err) + } + + if !strings.HasSuffix(path, "/bash") && path != "bash" { + t.Errorf("expected resolveShell() to prefer bash, got %q", path) + } +} + +// TestShellFlag_ReturnsMinusC verifies that shellFlag() returns "-c" on Unix. +func TestShellFlag_ReturnsMinusC(t *testing.T) { + t.Parallel() + + flag := shellFlag() + if flag != "-c" { + t.Errorf("expected shellFlag() = %q, got %q", "-c", flag) + } +} + +// TestIsWindows_ReturnsFalseOnUnix verifies that isWindows() returns false +// when compiled with the !windows build tag. +func TestIsWindows_ReturnsFalseOnUnix(t *testing.T) { + t.Parallel() + + if isWindows() { + t.Error("isWindows() returned true on a non-Windows build") + } +} + +// TestErrNoShellFound_Message verifies the sentinel error message is stable. +func TestErrNoShellFound_Message(t *testing.T) { + t.Parallel() + + expected := "no usable shell found on PATH" + if errNoShellFound.Error() != expected { + t.Errorf("errNoShellFound = %q, want %q", errNoShellFound.Error(), expected) + } +} From 5fb2b8b04f2d18a15f43acb49dcae1954bc933ba Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Mon, 29 Jun 2026 15:14:09 -0400 Subject: [PATCH 3/3] [quality] add unit tests for init.go AI function pointer wiring Verifies that init() correctly wires up ai.GetRegistry, ai.InitializeProviders, ai.SetClusterContextProviders, and ai.GetConfigManager function pointers. Signed-off-by: clubanderson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/agent/init_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 pkg/agent/init_test.go diff --git a/pkg/agent/init_test.go b/pkg/agent/init_test.go new file mode 100644 index 0000000000..96261c4ae2 --- /dev/null +++ b/pkg/agent/init_test.go @@ -0,0 +1,75 @@ +package agent + +import ( + "testing" + + "github.com/kubestellar/console/pkg/ai" +) + +// TestInit_WiresAIPackageFunctionPointers verifies that the package-level +// init() function in init.go correctly wires up the function pointer +// variables in the ai package. These pointers allow pkg/ai to call back +// into pkg/agent without creating an import cycle. +func TestInit_WiresAIPackageFunctionPointers(t *testing.T) { + t.Parallel() + + // By the time any test in this package runs, init() has already + // executed. All four function pointers should be non-nil. + + if ai.GetRegistry == nil { + t.Error("ai.GetRegistry is nil — init() did not wire it up") + } + if ai.InitializeProviders == nil { + t.Error("ai.InitializeProviders is nil — init() did not wire it up") + } + if ai.SetClusterContextProviders == nil { + t.Error("ai.SetClusterContextProviders is nil — init() did not wire it up") + } + if ai.GetConfigManager == nil { + t.Error("ai.GetConfigManager is nil — init() did not wire it up") + } +} + +// TestInit_GetRegistryReturnsNonNil verifies that the wired GetRegistry +// function returns a valid (non-nil) registry instance. +func TestInit_GetRegistryReturnsNonNil(t *testing.T) { + t.Parallel() + + if ai.GetRegistry == nil { + t.Skip("ai.GetRegistry not wired") + } + + reg := ai.GetRegistry() + if reg == nil { + t.Error("ai.GetRegistry() returned nil") + } +} + +// TestInit_GetConfigManagerReturnsNonNil verifies that the wired +// GetConfigManager function returns a valid (non-nil) config manager. +func TestInit_GetConfigManagerReturnsNonNil(t *testing.T) { + t.Parallel() + + if ai.GetConfigManager == nil { + t.Skip("ai.GetConfigManager not wired") + } + + mgr := ai.GetConfigManager() + if mgr == nil { + t.Error("ai.GetConfigManager() returned nil") + } +} + +// TestInit_SetClusterContextProvidersDoesNotPanic verifies that calling +// SetClusterContextProviders with nil arguments does not panic (graceful +// no-op for unconfigured environments). +func TestInit_SetClusterContextProvidersDoesNotPanic(t *testing.T) { + t.Parallel() + + if ai.SetClusterContextProviders == nil { + t.Skip("ai.SetClusterContextProviders not wired") + } + + // Should not panic with nil args + ai.SetClusterContextProviders(nil, nil) +}