mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-15 07:57:49 +00:00
0c11838326
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.
274 lines
7.8 KiB
Go
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")
|
|
}
|