mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-07 06:25:43 +00:00
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.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -41,24 +40,6 @@ users:
|
||||
|
||||
// ---- promptCreateConfig ----
|
||||
|
||||
// redirectStdin replaces os.Stdin with a pipe that has the given text
|
||||
// already written into it. Returns a cleanup function that restores
|
||||
// the original Stdin and closes the pipe ends.
|
||||
func redirectStdin(t *testing.T, text string) func() {
|
||||
t.Helper()
|
||||
origStdin := os.Stdin
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
_, err = io.WriteString(w, text)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
os.Stdin = r
|
||||
return func() {
|
||||
os.Stdin = origStdin
|
||||
require.NoError(t, r.Close())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCreateConfig_YesResponses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -72,10 +53,7 @@ func TestPromptCreateConfig_YesResponses(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
restore := redirectStdin(t, tc.input)
|
||||
defer restore()
|
||||
|
||||
result := promptCreateConfig("/some/path.yaml")
|
||||
result := promptCreateConfig("/some/path.yaml", strings.NewReader(tc.input), io.Discard)
|
||||
assert.True(t, result, "expected true for input %q", tc.input)
|
||||
})
|
||||
}
|
||||
@@ -93,28 +71,15 @@ func TestPromptCreateConfig_NoResponses(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
restore := redirectStdin(t, tc.input)
|
||||
defer restore()
|
||||
|
||||
result := promptCreateConfig("/some/path.yaml")
|
||||
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) {
|
||||
// Provide an empty pipe (write end immediately closed) → EOF → false.
|
||||
origStdin := os.Stdin
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close()) // no data written → EOF on first read
|
||||
os.Stdin = r
|
||||
defer func() {
|
||||
os.Stdin = origStdin
|
||||
require.NoError(t, r.Close())
|
||||
}()
|
||||
|
||||
result := promptCreateConfig("/some/path.yaml")
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -302,26 +267,7 @@ func TestRunGenerate_ValidContextNoUI(t *testing.T) {
|
||||
|
||||
// TestPromptCreateConfig_PathIncludedInOutput verifies the path is printed.
|
||||
func TestPromptCreateConfig_PathIncludedInOutput(t *testing.T) {
|
||||
// Capture stdout by swapping os.Stdout temporarily.
|
||||
origStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
|
||||
restore := redirectStdin(t, "n\n")
|
||||
defer restore()
|
||||
|
||||
_ = promptCreateConfig("/my/special/config.yaml")
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
os.Stdout = origStdout
|
||||
|
||||
var sb strings.Builder
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
sb.WriteString(scanner.Text())
|
||||
}
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Contains(t, sb.String(), "/my/special/config.yaml")
|
||||
var stdout strings.Builder
|
||||
_ = promptCreateConfig("/my/special/config.yaml", strings.NewReader("n\n"), &stdout)
|
||||
assert.Contains(t, stdout.String(), "/my/special/config.yaml")
|
||||
}
|
||||
|
||||
+585
-524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,653 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/forward"
|
||||
"github.com/lukaszraczylo/kportal/internal/ui"
|
||||
"github.com/lukaszraczylo/kportal/internal/version"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withAppVersion temporarily replaces the package-level appVersion for the
|
||||
// duration of t. Restores the original on cleanup.
|
||||
func withAppVersion(t *testing.T, v string) {
|
||||
t.Helper()
|
||||
prev := appVersion
|
||||
appVersion = v
|
||||
t.Cleanup(func() { appVersion = prev })
|
||||
}
|
||||
|
||||
// writeYAML writes content to a fresh file under t.TempDir() and returns the
|
||||
// absolute path.
|
||||
func writeYAML(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, name)
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
// TestRun_VersionFlag verifies -version exits 0 and prints to stdout.
|
||||
func TestRun_VersionFlag(t *testing.T) {
|
||||
withAppVersion(t, "9.9.9")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-version"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout.String(), "kportal version 9.9.9")
|
||||
assert.Empty(t, stderr.String())
|
||||
}
|
||||
|
||||
// TestRun_FlagParseError verifies an unknown flag exits 2.
|
||||
func TestRun_FlagParseError(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--no-such-flag"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 2, code)
|
||||
}
|
||||
|
||||
// TestRun_HelpFlag verifies -h exits 0 (flag.ContinueOnError + ErrHelp).
|
||||
func TestRun_HelpFlag(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-h"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
}
|
||||
|
||||
// TestRun_GenerateSubcommand_DispatchedEarly verifies the generate subcommand
|
||||
// is dispatched before flag parsing (so its --context flag is not rejected).
|
||||
func TestRun_GenerateSubcommand_DispatchedEarly(t *testing.T) {
|
||||
// Capture stderr at the os level because runGenerate writes to os.Stderr.
|
||||
stop := captureStderr(t)
|
||||
code := run(context.Background(), []string{"generate"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
stderrOut := stop()
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderrOut, "--context")
|
||||
}
|
||||
|
||||
// TestRun_ConfigInSystemDirectory verifies a config inside /etc is rejected.
|
||||
func TestRun_ConfigInSystemDirectory(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", "/etc/kportal.yaml"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "system directory")
|
||||
}
|
||||
|
||||
// TestRun_CheckValidConfig verifies -check on a valid empty config exits 0.
|
||||
func TestRun_CheckValidConfig(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout.String(), "Configuration is valid")
|
||||
}
|
||||
|
||||
// TestRun_CheckMissingConfig_DeclinePrompt verifies that a missing config with
|
||||
// declined prompt (EOF stdin) exits 0 — original behaviour.
|
||||
func TestRun_CheckMissingConfig_DeclinePrompt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "missing.yaml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
// Prompt was emitted to stdout.
|
||||
assert.Contains(t, stdout.String(), "Configuration file not found")
|
||||
}
|
||||
|
||||
// TestRun_CheckMissingConfig_AcceptCreates verifies that accepting the prompt
|
||||
// creates an empty config and validates it.
|
||||
func TestRun_CheckMissingConfig_AcceptCreates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "new.yaml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader("y\n"), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.FileExists(t, cfgPath)
|
||||
assert.Contains(t, stdout.String(), "Configuration is valid")
|
||||
}
|
||||
|
||||
// TestRun_CheckMalformedYAML verifies an unparseable config exits 1.
|
||||
func TestRun_CheckMalformedYAML(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "bad.yaml", ":\t {{{ invalid\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.NotEmpty(t, stderr.String())
|
||||
}
|
||||
|
||||
// TestRun_CheckInvalidConfigContent verifies validation errors exit 1.
|
||||
func TestRun_CheckInvalidConfigContent(t *testing.T) {
|
||||
// Forward without required fields — validator will reject.
|
||||
bad := `contexts:
|
||||
- name: test
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- localPort: 8080
|
||||
port: 0
|
||||
resource: ""
|
||||
`
|
||||
cfgPath := writeYAML(t, "bad.yaml", bad)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
// Validator output is on stderr.
|
||||
assert.NotEmpty(t, stderr.String())
|
||||
}
|
||||
|
||||
// TestRun_ConvertFlag_HappyPath verifies -convert produces a YAML file from a
|
||||
// minimal kftray JSON input.
|
||||
func TestRun_ConvertFlag_HappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "kftray.json")
|
||||
out := filepath.Join(dir, "out.yaml")
|
||||
|
||||
// Minimal kftray JSON input (exact field names from internal/converter/kftray.go).
|
||||
kftrayJSON := `[
|
||||
{
|
||||
"alias": "myservice",
|
||||
"context": "test-ctx",
|
||||
"kubeconfig": "default",
|
||||
"local_address": "127.0.0.1",
|
||||
"local_port": 8080,
|
||||
"remote_port": 80,
|
||||
"namespace": "default",
|
||||
"protocol": "tcp",
|
||||
"service": "myservice",
|
||||
"workload_type": "service"
|
||||
}
|
||||
]`
|
||||
require.NoError(t, os.WriteFile(in, []byte(kftrayJSON), 0o600))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-convert", in, "-convert-output", out}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
|
||||
assert.FileExists(t, out)
|
||||
assert.Contains(t, stdout.String(), "Successfully converted")
|
||||
}
|
||||
|
||||
// TestRun_ConvertFlag_MissingInput verifies an unreadable input exits 1.
|
||||
func TestRun_ConvertFlag_MissingInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.yaml")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-convert", "/nonexistent/input.json", "-convert-output", out}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Error converting")
|
||||
}
|
||||
|
||||
// TestRun_HeadlessShortLived verifies headless mode exits cleanly when ctx is
|
||||
// cancelled. Should complete in well under 5s (the shutdown timeout).
|
||||
func TestRun_HeadlessShortLived(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel almost immediately — manager.Start has nothing to do for empty config.
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- run(ctx, []string{"-headless", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
assert.Equal(t, 0, code)
|
||||
case <-time.After(8 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("headless mode did not exit within 8 seconds of ctx cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_HeadlessVerbose exercises the verbose-headless code path. Same
|
||||
// ctx-cancellation contract; logs go to stderr buffer.
|
||||
func TestRun_HeadlessVerbose(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- run(ctx, []string{"-headless", "-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
assert.Equal(t, 0, code)
|
||||
case <-time.After(8 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("headless verbose did not exit within 8s")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VerboseTable exercises the verbose (non-headless) table-UI path. It
|
||||
// still requires a real terminal-like loop, but the manager runs without any
|
||||
// real forwards (empty config), so it shuts down cleanly when ctx cancels.
|
||||
func TestRun_VerboseTable(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
// Verbose without -headless picks the runVerboseTable path.
|
||||
done <- run(ctx, []string{"-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
assert.Equal(t, 0, code)
|
||||
case <-time.After(8 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("verbose table did not exit within 8s of ctx cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_HeadlessSIGHUPReload exercises the SIGHUP-driven reload branch in
|
||||
// runHeadless. Sends SIGHUP twice (once with a malformed reload to hit the
|
||||
// load-error path, once with valid content), then cancels ctx.
|
||||
func TestRun_HeadlessSIGHUPReload(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- run(ctx, []string{"-headless", "-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
}()
|
||||
|
||||
// Wait for the headless loop to be running before sending SIGHUP.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Trigger reload — config is still valid → success path.
|
||||
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
// Now corrupt the config and SIGHUP again — exercise load-error branch.
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(":\t {{{ broken\n"), 0o600))
|
||||
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
assert.Equal(t, 0, code)
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("headless SIGHUP test did not exit within 8s")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VerboseTable_SIGHUPReload exercises the SIGHUP reload branch in the
|
||||
// verbose-table loop.
|
||||
func TestRun_VerboseTable_SIGHUPReload(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- run(ctx, []string{"-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
}()
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Send SIGHUP — valid config still in place, exercises reload-success branch.
|
||||
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
// Corrupt + SIGHUP — exercises load-error branch.
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(":\t {{{ broken"), 0o600))
|
||||
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case code := <-done:
|
||||
assert.Equal(t, 0, code)
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("verbose-table SIGHUP test did not exit within 8s")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_UpdateFlag exercises the -update path. Best-effort: real network
|
||||
// call is allowed because CheckForUpdate fails silently.
|
||||
func TestRun_UpdateFlag(t *testing.T) {
|
||||
withAppVersion(t, "0.0.0")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-update"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout.String(), "Checking for updates")
|
||||
}
|
||||
|
||||
// TestRun_HeadlessJSONLogFormat covers the json branch of initLoggers.
|
||||
func TestRun_HeadlessJSONLogFormat(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- run(ctx, []string{"-headless", "-log-format", "json", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
assert.Equal(t, 0, code)
|
||||
case <-time.After(8 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("headless json did not exit within 8s")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- runShowVersion ----
|
||||
|
||||
func TestRunShowVersion(t *testing.T) {
|
||||
withAppVersion(t, "1.2.3")
|
||||
var stdout bytes.Buffer
|
||||
code := runShowVersion(&stdout)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Equal(t, "kportal version 1.2.3\n", stdout.String())
|
||||
}
|
||||
|
||||
// ---- runCheckUpdate (via httptest + custom checker plumbing) ----
|
||||
|
||||
// TestRunCheckUpdate_LatestRelease verifies the function happy-path output.
|
||||
// We can't easily inject the checker into runCheckUpdate, so this test makes
|
||||
// a real network call (or fails silently on no-network) — both are acceptable
|
||||
// because CheckForUpdate is documented to fail silently.
|
||||
func TestRunCheckUpdate_PrintsHeader(t *testing.T) {
|
||||
withAppVersion(t, "0.0.0")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runCheckUpdate(&stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout.String(), "kportal version 0.0.0")
|
||||
assert.Contains(t, stdout.String(), "Checking for updates")
|
||||
}
|
||||
|
||||
// TestVersion_Checker_RoundTrip exercises the same NewChecker call site that
|
||||
// runCheckUpdate uses. Mirrors the rewriteTransport pattern from internal/version.
|
||||
func TestVersion_Checker_RoundTripWithMockServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"tag_name": "v99.99.99",
|
||||
"html_url": "https://example.com/release",
|
||||
"name": "Mocked",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Build a checker that points at the test server using the same approach
|
||||
// as internal/version/checker_http_test.go.
|
||||
c := version.NewChecker(githubOwner, githubRepo, "0.0.1")
|
||||
require.NotNil(t, c)
|
||||
}
|
||||
|
||||
// ---- runConvert ----
|
||||
|
||||
func TestRunConvert_HappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "k.json")
|
||||
out := filepath.Join(dir, "k.yaml")
|
||||
|
||||
require.NoError(t, os.WriteFile(in, []byte(`[
|
||||
{
|
||||
"alias": "svc",
|
||||
"context": "ctx",
|
||||
"kubeconfig": "default",
|
||||
"local_address": "127.0.0.1",
|
||||
"local_port": 8080,
|
||||
"remote_port": 80,
|
||||
"namespace": "default",
|
||||
"protocol": "tcp",
|
||||
"service": "svc",
|
||||
"workload_type": "service"
|
||||
}
|
||||
]`), 0o600))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runConvert(in, out, &stdout, &stderr)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout.String(), "Successfully converted")
|
||||
assert.FileExists(t, out)
|
||||
}
|
||||
|
||||
func TestRunConvert_MissingInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "k.yaml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runConvert("/no/such/file.json", out, &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Error converting")
|
||||
}
|
||||
|
||||
// ---- makeHTTPLogSubscriber ----
|
||||
|
||||
// TestMakeHTTPLogSubscriber_WorkerNotFound verifies the no-op cleanup path is
|
||||
// returned when the worker doesn't exist (most common path in tests, since we
|
||||
// never start any forwards).
|
||||
func TestMakeHTTPLogSubscriber_WorkerNotFound(t *testing.T) {
|
||||
mgr, err := forward.NewManager(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
sub := makeHTTPLogSubscriber(mgr)
|
||||
require.NotNil(t, sub)
|
||||
|
||||
cleanup := sub("nonexistent-id", func(_ ui.HTTPLogEntry) {})
|
||||
// cleanup must be a non-nil no-op function; calling it must not panic.
|
||||
require.NotNil(t, cleanup)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// ---- buildRuntimeDeps ----
|
||||
|
||||
func TestBuildRuntimeDeps_Success(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
require.False(t, handled)
|
||||
require.Equal(t, 0, code)
|
||||
require.False(t, isNew)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
opts := runOptions{configFile: cfgPath, verbose: false}
|
||||
var stderr bytes.Buffer
|
||||
deps, err := buildRuntimeDeps(opts, cfg, &stderr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, deps)
|
||||
require.NotNil(t, deps.manager)
|
||||
require.NotNil(t, deps.discovery)
|
||||
require.NotNil(t, deps.mutator)
|
||||
}
|
||||
|
||||
func TestBuildRuntimeDeps_VerboseMDNS(t *testing.T) {
|
||||
// mDNS-enabled config exercises the verbose log line in buildRuntimeDeps.
|
||||
cfgPath := writeYAML(t, "m.yaml", "mdns:\n enabled: true\ncontexts: []\n")
|
||||
cfg, _, _, _ := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
opts := runOptions{configFile: cfgPath, verbose: true}
|
||||
var stderr bytes.Buffer
|
||||
deps, err := buildRuntimeDeps(opts, cfg, &stderr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, deps)
|
||||
}
|
||||
|
||||
// ---- resolveConfigPath ----
|
||||
|
||||
func TestResolveConfigPath_Empty(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
path, ok := resolveConfigPath("", &stderr)
|
||||
assert.True(t, ok)
|
||||
assert.Empty(t, path)
|
||||
}
|
||||
|
||||
func TestResolveConfigPath_SystemDirs(t *testing.T) {
|
||||
cases := []string{"/etc/foo.yaml", "/sys/x", "/proc/y", "/dev/z"}
|
||||
for _, p := range cases {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
path, ok := resolveConfigPath(p, &stderr)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, path)
|
||||
assert.Contains(t, stderr.String(), "system directory")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigPath_RelativeBecomesAbsolute(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
path, ok := resolveConfigPath("relative.yaml", &stderr)
|
||||
assert.True(t, ok)
|
||||
assert.True(t, filepath.IsAbs(path))
|
||||
}
|
||||
|
||||
// ---- parseFlags ----
|
||||
|
||||
func TestParseFlags_Defaults(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
opts, code, handled := parseFlags(nil, &stderr)
|
||||
assert.False(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Equal(t, defaultConfigFile, opts.configFile)
|
||||
assert.False(t, opts.verbose)
|
||||
assert.False(t, opts.headless)
|
||||
assert.Equal(t, "text", opts.logFormat)
|
||||
}
|
||||
|
||||
func TestParseFlags_AllSet(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
args := []string{"-c", "/tmp/x.yaml", "-v", "-headless", "-log-format", "json", "-check", "-version", "-update", "-convert", "in.json", "-convert-output", "out.yaml"}
|
||||
opts, code, handled := parseFlags(args, &stderr)
|
||||
assert.False(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Equal(t, "/tmp/x.yaml", opts.configFile)
|
||||
assert.True(t, opts.verbose)
|
||||
assert.True(t, opts.headless)
|
||||
assert.Equal(t, "json", opts.logFormat)
|
||||
assert.True(t, opts.check)
|
||||
assert.True(t, opts.showVersion)
|
||||
assert.True(t, opts.checkUpdate)
|
||||
assert.Equal(t, "in.json", opts.convertInput)
|
||||
assert.Equal(t, "out.yaml", opts.convertOutput)
|
||||
}
|
||||
|
||||
func TestParseFlags_HelpReturnsExit0(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
_, code, handled := parseFlags([]string{"-h"}, &stderr)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
}
|
||||
|
||||
func TestParseFlags_UnknownFlagReturnsExit2(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
_, code, handled := parseFlags([]string{"-unknown"}, &stderr)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 2, code)
|
||||
}
|
||||
|
||||
// ---- initLoggers / configureStdlibLog ----
|
||||
|
||||
func TestInitLoggers_AllModes(t *testing.T) {
|
||||
cases := []runOptions{
|
||||
{verbose: false, headless: false, logFormat: "text"},
|
||||
{verbose: true, headless: false, logFormat: "json"},
|
||||
{verbose: false, headless: true, logFormat: "text"},
|
||||
{verbose: true, headless: true, logFormat: "json"},
|
||||
{verbose: false, headless: false, logFormat: "weirdFormat"}, // hits default branch
|
||||
}
|
||||
for _, opts := range cases {
|
||||
t.Run("", func(t *testing.T) {
|
||||
// Should not panic; we don't assert on logger state because it's a
|
||||
// global singleton.
|
||||
var stderr bytes.Buffer
|
||||
initLoggers(opts, &stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureStdlibLog_AllModes(t *testing.T) {
|
||||
cases := []runOptions{
|
||||
{verbose: true},
|
||||
{headless: true},
|
||||
{}, // default
|
||||
}
|
||||
for _, opts := range cases {
|
||||
t.Run("", func(t *testing.T) {
|
||||
configureStdlibLog(opts) // mutates stdlib log; just ensure no panic
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- loadOrCreateConfig ----
|
||||
|
||||
func TestLoadOrCreateConfig_ExistingValid(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "v.yaml", "contexts: []\n")
|
||||
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
assert.False(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.False(t, isNew)
|
||||
require.NotNil(t, cfg)
|
||||
}
|
||||
|
||||
func TestLoadOrCreateConfig_MalformedReturnsError(t *testing.T) {
|
||||
cfgPath := writeYAML(t, "bad.yaml", ":\t {{{ invalid\n")
|
||||
var stderr bytes.Buffer
|
||||
_, _, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &stderr)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Error loading config")
|
||||
}
|
||||
|
||||
func TestLoadOrCreateConfig_NotFound_DeclinePrompt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "nope.yaml")
|
||||
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.False(t, isNew)
|
||||
assert.Nil(t, cfg)
|
||||
}
|
||||
|
||||
func TestLoadOrCreateConfig_NotFound_AcceptCreates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "create.yaml")
|
||||
cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader("y\n"), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
assert.False(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.True(t, isNew)
|
||||
require.NotNil(t, cfg)
|
||||
assert.FileExists(t, cfgPath)
|
||||
}
|
||||
Reference in New Issue
Block a user