From 2feb43f0bb269be7d61ec8924f0fa4fab655913e Mon Sep 17 00:00:00 2001 From: David Norton Date: Wed, 8 Apr 2026 07:10:55 -0500 Subject: [PATCH 1/2] Add integration test harness with fake Kubernetes API servers Closes #72 Co-Authored-By: Claude Sonnet 4.6 --- cmd/integration_test.go | 133 ++++++++++++++++++++++++++++ cmd/testharness_test.go | 192 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 cmd/integration_test.go create mode 100644 cmd/testharness_test.go diff --git a/cmd/integration_test.go b/cmd/integration_test.go new file mode 100644 index 0000000..2a877de --- /dev/null +++ b/cmd/integration_test.go @@ -0,0 +1,133 @@ +//go:build integration + +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func podListResponse(names ...string) map[string]interface{} { + items := make([]interface{}, 0, len(names)) + for _, name := range names { + items = append(items, map[string]interface{}{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": map[string]interface{}{"name": name, "namespace": "default"}, + "status": map[string]interface{}{"phase": "Running"}, + }) + } + return map[string]interface{}{ + "kind": "PodList", + "apiVersion": "v1", + "metadata": map[string]interface{}{"resourceVersion": "1"}, + "items": items, + } +} + +// TestGetPodsJSON verifies that pod lists from multiple contexts are merged into +// a single JSON List and that each item carries a metadata.context annotation. +func TestGetPodsJSON(t *testing.T) { + h := NewHarness(t) + s1 := h.AddContext("ctx1") + s2 := h.AddContext("ctx2") + + s1.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("pod-a", "pod-b")) + s2.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("pod-c")) + + out, err := h.Run("get", "pods", "-o", "json") + require.NoError(t, err) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &result)) + + items, ok := result["items"].([]interface{}) + require.True(t, ok) + assert.Len(t, items, 3) + + contextsSeen := make(map[string]bool) + for _, item := range items { + m, ok := item.(map[string]interface{}) + require.True(t, ok) + meta, ok := m["metadata"].(map[string]interface{}) + require.True(t, ok) + ctx, _ := meta["context"].(string) + contextsSeen[ctx] = true + } + assert.True(t, contextsSeen["ctx1"], "expected items from ctx1") + assert.True(t, contextsSeen["ctx2"], "expected items from ctx2") +} + +// TestVersion verifies that version output includes context names and the +// server version returned by the fake API. +func TestVersion(t *testing.T) { + h := NewHarness(t) + h.AddContext("ctx1") + h.AddContext("ctx2") + + out, err := h.Run("version") + require.NoError(t, err) + + assert.Contains(t, out, "CONTEXT") + assert.Contains(t, out, "SERVER VERSION") + assert.Contains(t, out, "ctx1") + assert.Contains(t, out, "ctx2") + assert.Contains(t, out, "v1.28.0") +} + +// TestExcludeContext verifies that --exclude filters out matching contexts. +func TestExcludeContext(t *testing.T) { + h := NewHarness(t) + s1 := h.AddContext("prod-east") + s2 := h.AddContext("dev-west") + + s1.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("prod-pod")) + s2.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("dev-pod")) + + excludePatterns = []string{"dev"} + t.Cleanup(func() { excludePatterns = []string{} }) + + out, err := h.Run("get", "pods", "-o", "json") + require.NoError(t, err) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &result)) + + items, ok := result["items"].([]interface{}) + require.True(t, ok) + require.Len(t, items, 1) + + m := items[0].(map[string]interface{}) + meta := m["metadata"].(map[string]interface{}) + assert.Equal(t, "prod-east", meta["context"]) +} + +// TestIncludeContext verifies that --include limits results to matching contexts. +func TestIncludeContext(t *testing.T) { + h := NewHarness(t) + s1 := h.AddContext("prod-east") + s2 := h.AddContext("dev-west") + + s1.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("prod-pod")) + s2.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("dev-pod")) + + filterPatterns = []string{"prod"} + t.Cleanup(func() { filterPatterns = []string{} }) + + out, err := h.Run("get", "pods", "-o", "json") + require.NoError(t, err) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &result)) + + items, ok := result["items"].([]interface{}) + require.True(t, ok) + require.Len(t, items, 1) + + m := items[0].(map[string]interface{}) + meta := m["metadata"].(map[string]interface{}) + assert.Equal(t, "prod-east", meta["context"]) +} diff --git a/cmd/testharness_test.go b/cmd/testharness_test.go new file mode 100644 index 0000000..24b991d --- /dev/null +++ b/cmd/testharness_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// FakeServer is a minimal fake Kubernetes API server for use in integration tests. +// It registers sensible defaults for API discovery endpoints and lets tests +// register additional handlers for specific paths via HandleJSON. +type FakeServer struct { + Server *httptest.Server + mux *http.ServeMux +} + +func newFakeServer(t *testing.T) *FakeServer { + t.Helper() + mux := http.NewServeMux() + fs := &FakeServer{mux: mux} + fs.registerDefaults() + fs.Server = httptest.NewServer(mux) + t.Cleanup(fs.Server.Close) + return fs +} + +// HandleJSON registers a handler for path that encodes v as a JSON response. +// Calling HandleJSON again for the same path replaces the previous handler. +func (fs *FakeServer) HandleJSON(path string, v interface{}) { + fs.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) +} + +func (fs *FakeServer) registerDefaults() { + fs.HandleJSON("/api", map[string]interface{}{ + "kind": "APIVersions", + "apiVersion": "v1", + "versions": []string{"v1"}, + "serverAddressByClientCIDRs": []map[string]interface{}{ + {"clientCIDR": "0.0.0.0/0", "serverAddress": ""}, + }, + }) + fs.HandleJSON("/apis", map[string]interface{}{ + "kind": "APIGroupList", + "apiVersion": "v1", + "groups": []interface{}{}, + }) + fs.HandleJSON("/api/v1", map[string]interface{}{ + "kind": "APIResourceList", + "groupVersion": "v1", + "resources": []map[string]interface{}{ + { + "name": "pods", + "namespaced": true, + "kind": "Pod", + "verbs": []string{"create", "delete", "get", "list", "patch", "update", "watch"}, + }, + { + "name": "nodes", + "namespaced": false, + "kind": "Node", + "verbs": []string{"create", "delete", "get", "list", "patch", "update", "watch"}, + }, + }, + }) + fs.HandleJSON("/version", map[string]interface{}{ + "major": "1", + "minor": "28", + "gitVersion": "v1.28.0", + "gitCommit": "abc1234", + "gitTreeState": "clean", + "buildDate": "2023-08-15T10:20:12Z", + "goVersion": "go1.20.7", + "compiler": "gc", + "platform": "linux/amd64", + }) +} + +// Harness manages a set of fake servers and a temporary kubeconfig for integration testing. +type Harness struct { + t *testing.T + servers map[string]*FakeServer + contextOrder []string + kubeconfigPath string + tmpDir string +} + +// NewHarness creates a new Harness backed by a temporary directory that is +// cleaned up when the test ends. +func NewHarness(t *testing.T) *Harness { + t.Helper() + tmpDir := t.TempDir() + return &Harness{ + t: t, + servers: make(map[string]*FakeServer), + kubeconfigPath: filepath.Join(tmpDir, "kubeconfig"), + tmpDir: tmpDir, + } +} + +// AddContext creates a fake server, registers it as a kubeconfig context with +// the given name, and returns the server so the test can register handlers. +func (h *Harness) AddContext(name string) *FakeServer { + h.t.Helper() + fs := newFakeServer(h.t) + h.servers[name] = fs + h.contextOrder = append(h.contextOrder, name) + h.writeKubeconfig() + return fs +} + +func (h *Harness) writeKubeconfig() { + h.t.Helper() + + var clusters, contexts, users []interface{} + for _, name := range h.contextOrder { + fs := h.servers[name] + clusters = append(clusters, map[string]interface{}{ + "name": name, + "cluster": map[string]interface{}{ + "server": fs.Server.URL, + }, + }) + contexts = append(contexts, map[string]interface{}{ + "name": name, + "context": map[string]interface{}{ + "cluster": name, + "user": name, + }, + }) + users = append(users, map[string]interface{}{ + "name": name, + "user": map[string]interface{}{}, + }) + } + + kubeconfig := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Config", + "clusters": clusters, + "contexts": contexts, + "users": users, + "current-context": h.contextOrder[0], + } + + data, err := yaml.Marshal(kubeconfig) + require.NoError(h.t, err) + require.NoError(h.t, os.WriteFile(h.kubeconfigPath, data, 0600)) +} + +// Run executes a kubectl-x command and returns the captured stdout. +// KUBECONFIG is pointed at the harness kubeconfig and HOME is set to a temp +// dir so kubectl's discovery cache is isolated from the real home directory. +func (h *Harness) Run(subcommand string, args ...string) (string, error) { + h.t.Helper() + + h.t.Setenv("KUBECONFIG", h.kubeconfigPath) + h.t.Setenv("HOME", h.tmpDir) // isolate kubectl's cache + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(h.t, err) + os.Stdout = w + + var buf bytes.Buffer + readerDone := make(chan struct{}) + go func() { + io.Copy(&buf, r) + close(readerDone) + }() + + runErr := runCommand(subcommand, args) + + w.Close() + <-readerDone + os.Stdout = oldStdout + r.Close() + + return buf.String(), runErr +} From 448d1e1c9ba4f19629a182db9b7c4eb000891040 Mon Sep 17 00:00:00 2001 From: David Norton Date: Wed, 8 Apr 2026 07:29:42 -0500 Subject: [PATCH 2/2] Run integration tests in CI Install kubectl on the CI runner and pass -tags integration so the integration test suite is included in every test run. --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 308d22f..14176a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,14 @@ jobs: exit 1 fi + - name: Install kubectl + run: | + curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + sudo mv kubectl /usr/local/bin/kubectl + - name: Run tests - run: go test -v -cover ./... + run: go test -v -cover -tags integration ./... - name: Build run: go build -v .