Files
kportal/cmd/kportal/helpers_test.go
T
lukaszraczylo 0c11838326 refactor(cmd): extract main() into testable run() + per-mode helpers
main() body is now os.Exit(run(ctx, args, stdin, stdout, stderr))
where ctx comes from signal.NotifyContext and IO is io.Reader/Writer
instead of os.Std*. run() parses flags via a fresh flag.NewFlagSet
(no more package-level flag.* globals) and dispatches to:

  runShowVersion(stdout)
  runCheckUpdate(stdout, stderr)
  runConvert(input, output, stdout, stderr)
  runHeadless(ctx, opts)
  runVerboseTable(ctx, opts)
  runInteractive(ctx, opts)

Helpers extracted: parseFlags, resolveConfigPath, initLoggers,
configureStdlibLog, loadOrCreateConfig, buildRuntimeDeps,
makeHTTPLogSubscriber, shutdownManager. Signal-loop and fsnotify
watcher exit cleanly on ctx.Done() (previously chan-based with
unconditional os.Exit).

Behaviour preserved by smoke checks: -version, -update, generate
without --context, generate with bogus context, -check on
missing/valid config, -headless start+SIGINT shutdown.

Coverage: cmd/kportal 17.4% -> 71.3%. New run_test.go exercises
run() and every run* mode. main() and runInteractive remain at 0%
(TTY-required); not feasible without a tea.Program factory
abstraction, which would add complexity for minimal coverage gain.
2026-05-06 15:03:16 +01:00

274 lines
7.8 KiB
Go

package main
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeKubeconfig writes a minimal kubeconfig file to dir with a single context
// named contextName and returns the path.
func fakeKubeconfig(t *testing.T, dir, contextName string) string {
t.Helper()
content := `apiVersion: v1
clusters:
- cluster:
server: https://localhost:6443
name: fake-cluster
contexts:
- context:
cluster: fake-cluster
namespace: default
user: fake-user
name: ` + contextName + `
current-context: ` + contextName + `
kind: Config
preferences: {}
users:
- name: fake-user
user: {}
`
path := filepath.Join(dir, "kubeconfig")
require.NoError(t, os.WriteFile(path, []byte(content), 0600))
return path
}
// ---- promptCreateConfig ----
func TestPromptCreateConfig_YesResponses(t *testing.T) {
cases := []struct {
name string
input string
}{
{"empty enter", "\n"},
{"lowercase y", "y\n"},
{"uppercase Y", "Y\n"}, // ToLower normalises it
{"yes word", "yes\n"},
{"YES word", "YES\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := promptCreateConfig("/some/path.yaml", strings.NewReader(tc.input), io.Discard)
assert.True(t, result, "expected true for input %q", tc.input)
})
}
}
func TestPromptCreateConfig_NoResponses(t *testing.T) {
cases := []struct {
name string
input string
}{
{"lowercase n", "n\n"},
{"uppercase N", "N\n"},
{"no word", "no\n"},
{"other text", "nope\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := promptCreateConfig("/some/path.yaml", strings.NewReader(tc.input), io.Discard)
assert.False(t, result, "expected false for input %q", tc.input)
})
}
}
func TestPromptCreateConfig_EOFReturnsFalse(t *testing.T) {
// Empty reader → EOF on first read → no data → false.
result := promptCreateConfig("/some/path.yaml", strings.NewReader(""), io.Discard)
assert.False(t, result, "EOF should return false")
}
// ---- contains ----
func TestContains_Present(t *testing.T) {
assert.True(t, contains([]string{"a", "b", "c"}, "b"))
}
func TestContains_Absent(t *testing.T) {
assert.False(t, contains([]string{"a", "b", "c"}, "d"))
}
func TestContains_EmptySlice(t *testing.T) {
assert.False(t, contains([]string{}, "x"))
}
func TestContains_EmptyNeedle(t *testing.T) {
assert.True(t, contains([]string{"", "a"}, ""))
}
// ---- resolveGenerateConfigPath ----
func TestResolveGenerateConfigPath_EmptyPath(t *testing.T) {
path, ok := resolveGenerateConfigPath("")
assert.False(t, ok)
assert.Empty(t, path)
}
func TestResolveGenerateConfigPath_SystemDirs(t *testing.T) {
sysDirs := []string{
"/etc/passwd",
"/sys/kernel/config",
"/proc/cpuinfo",
"/dev/null",
}
for _, d := range sysDirs {
t.Run(d, func(t *testing.T) {
path, ok := resolveGenerateConfigPath(d)
assert.False(t, ok, "system path should be rejected: %s", d)
assert.Empty(t, path)
})
}
}
func TestResolveGenerateConfigPath_ValidPath(t *testing.T) {
// A relative path should be resolved to an absolute, cleaned path.
path, ok := resolveGenerateConfigPath("relative/config.yaml")
assert.True(t, ok)
assert.True(t, strings.HasPrefix(path, "/"), "should be absolute")
assert.True(t, strings.HasSuffix(path, "relative/config.yaml"))
}
func TestResolveGenerateConfigPath_AbsolutePath(t *testing.T) {
tmpDir := t.TempDir()
configPath := tmpDir + "/kportal.yaml"
path, ok := resolveGenerateConfigPath(configPath)
assert.True(t, ok)
assert.Equal(t, configPath, path)
}
// ---- runGenerate ----
// captureStderr swaps os.Stderr for a pipe and returns a function that
// restores it and returns whatever was written.
func captureStderr(t *testing.T) func() string {
t.Helper()
origStderr := os.Stderr
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stderr = w
return func() string {
_ = w.Close()
os.Stderr = origStderr
var sb strings.Builder
_, _ = io.Copy(&sb, r)
_ = r.Close()
return sb.String()
}
}
func TestRunGenerate_MissingContextFlag(t *testing.T) {
// --context is required; omitting it should return exit-code 1.
stop := captureStderr(t)
code := runGenerate([]string{})
stderr := stop()
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "--context")
}
func TestRunGenerate_HelpFlag(t *testing.T) {
// -h / --help should return exit-code 0 (flag.ContinueOnError + ErrHelp).
stop := captureStderr(t)
code := runGenerate([]string{"-h"})
_ = stop()
assert.Equal(t, 0, code)
}
func TestRunGenerate_UnknownFlag(t *testing.T) {
// An unrecognised flag should return exit-code 1.
stop := captureStderr(t)
code := runGenerate([]string{"--unknown-flag=xyz"})
_ = stop()
assert.Equal(t, 1, code)
}
func TestRunGenerate_SystemDirConfig(t *testing.T) {
// A config path inside a system directory should return exit-code 1.
stop := captureStderr(t)
code := runGenerate([]string{"--context=minikube", "--config=/etc/kportal.yaml"})
stderr := stop()
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "system directory")
}
func TestRunGenerate_ContextNotInKubeconfig(t *testing.T) {
// A context that does not exist in kubeconfig should return exit-code 1.
// This relies on k8s.NewClientPool() succeeding (it reads ~/.kube/config or
// returns an empty pool) and ListContexts() returning a set that does not
// contain the requested name.
tmpDir := t.TempDir()
configPath := tmpDir + "/kportal.yaml"
stop := captureStderr(t)
code := runGenerate([]string{
"--context=this-context-does-not-exist-in-any-kubeconfig-xyz",
"--config=" + configPath,
})
stderr := stop()
assert.Equal(t, 1, code)
// Either the context was not found, OR k8s client setup failed — both are
// valid error paths that return 1.
assert.NotEmpty(t, stderr)
}
// TestRunGenerate_MalformedConfig verifies that a config file with invalid YAML
// causes runGenerate to return exit-code 1 before calling ui.RunGenerate.
func TestRunGenerate_MalformedConfig(t *testing.T) {
tmpDir := t.TempDir()
// Create a fake kubeconfig with a known context name.
kubecfgPath := fakeKubeconfig(t, tmpDir, "test-ctx")
t.Setenv("KUBECONFIG", kubecfgPath)
// Write an invalid YAML config file.
configPath := filepath.Join(tmpDir, "bad.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(":\t invalid yaml {{{\n"), 0600))
stop := captureStderr(t)
code := runGenerate([]string{
"--context=test-ctx",
"--config=" + configPath,
})
stderr := stop()
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "failed to load config")
}
// TestRunGenerate_ValidContextNoUI verifies runGenerate error-handling when
// ui.RunGenerate cannot open a TTY (always the case in non-interactive test
// environments). The function should return exit-code 1 and print the error.
func TestRunGenerate_ValidContextNoUI(t *testing.T) {
tmpDir := t.TempDir()
kubecfgPath := fakeKubeconfig(t, tmpDir, "test-ctx")
t.Setenv("KUBECONFIG", kubecfgPath)
// Config file does not exist — ErrConfigNotFound is acceptable; code
// proceeds to ui.RunGenerate which fails (no TTY in tests).
configPath := filepath.Join(tmpDir, "nonexistent.yaml")
stop := captureStderr(t)
code := runGenerate([]string{
"--context=test-ctx",
"--config=" + configPath,
})
stderr := stop()
// Either the UI failed (exit 1) or — on rare CI with a TTY — it was
// cancelled (also exit 1). Both are acceptable outcomes for this test.
assert.Equal(t, 1, code)
_ = stderr // error message varies by environment
}
// ---- promptCreateConfig output via bufio path ----
// TestPromptCreateConfig_PathIncludedInOutput verifies the path is printed.
func TestPromptCreateConfig_PathIncludedInOutput(t *testing.T) {
var stdout strings.Builder
_ = promptCreateConfig("/my/special/config.yaml", strings.NewReader("n\n"), &stdout)
assert.Contains(t, stdout.String(), "/my/special/config.yaml")
}