Files
kportal/cmd/kportal/run_test.go
T
4d263319ab feat: add --context to forward only selected contexts (#85)
* feat: add --context to forward only selected contexts

kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.

--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:

    kportal --context development-team-a
    kportal --context team-a,team-b -headless
    kportal --context team-a --context team-b

An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.

Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.

Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.

Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.

* fix(config): keep the running config when no selected context remains

A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.

LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.

Refs #80

* fix(ui): apply new table columns after a successful SIGHUP reload

The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.

Refs #80

* style: gofmt run_test.go

---------

Co-authored-by: André Cruz <[email protected]>
2026-10-02 02:33:29 +01:00

843 lines
28 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/lukaszraczylo/kportal/internal/config"
"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)
assert.Empty(t, opts.contexts, "no -context means every context is forwarded")
}
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)
}
// ---- -context selection ----
func TestParseFlags_ContextSelection(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{name: "single", args: []string{"-context", "team-a"}, want: []string{"team-a"}},
{name: "repeated", args: []string{"-context", "team-a", "-context", "team-b"}, want: []string{"team-a", "team-b"}},
{name: "comma separated", args: []string{"-context", "team-a,team-b"}, want: []string{"team-a", "team-b"}},
{name: "equals form", args: []string{"-context=team-a,team-b"}, want: []string{"team-a", "team-b"}},
{name: "double dash", args: []string{"--context", "team-a"}, want: []string{"team-a"}},
{
name: "repeated and comma mixed, de-duplicated",
args: []string{"-context", "team-a,team-b", "-context", "team-a", "-context", " team-c "},
want: []string{"team-a", "team-b", "team-c"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stderr bytes.Buffer
opts, code, handled := parseFlags(tt.args, &stderr)
require.False(t, handled)
assert.Equal(t, 0, code)
assert.Equal(t, tt.want, opts.contexts)
})
}
}
// TestParseFlags_EmptyContextRejected keeps "-context=$UNSET_VAR" in a script
// from silently degrading to "forward everything".
func TestParseFlags_EmptyContextRejected(t *testing.T) {
var stderr bytes.Buffer
_, code, handled := parseFlags([]string{"-context="}, &stderr)
assert.True(t, handled)
assert.Equal(t, 2, code)
}
// sharedPortConfigBody is the layout from issue #80: the same service on the
// same local port in two per-team clusters.
const sharedPortConfigBody = `contexts:
- name: team-a
namespaces:
- name: rate-service
forwards:
- resource: pod/rpc-server
protocol: tcp
port: 50051
localPort: 3004
- name: team-b
namespaces:
- name: rate-service
forwards:
- resource: pod/rpc-server
protocol: tcp
port: 50051
localPort: 3004
`
func writeSharedPortConfig(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), ".kportal.yaml")
require.NoError(t, os.WriteFile(path, []byte(sharedPortConfigBody), 0600))
return path
}
// TestRun_CheckWithContextSelection is the end-to-end proof of issue #80:
// selecting a single context makes a config that reuses local ports valid.
func TestRun_CheckWithContextSelection(t *testing.T) {
path := writeSharedPortConfig(t)
t.Run("without selection the shared port is a conflict", func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", path, "-check"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "Duplicate local port 3004")
})
t.Run("selecting one context is valid", func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
assert.Contains(t, stdout.String(), "Configuration is valid")
})
t.Run("selecting both brings the conflict back", func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a,team-b"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "Duplicate local port 3004")
})
}
// TestRun_CheckUnknownContext verifies the typo guard.
func TestRun_CheckUnknownContext(t *testing.T) {
path := writeSharedPortConfig(t)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-z"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), `unknown context "team-z"`)
// The message should point at what does exist.
assert.Contains(t, stderr.String(), "team-a")
assert.Contains(t, stderr.String(), "team-b")
}
// TestRun_CheckContextWithNewlyCreatedConfig makes sure -context does not turn
// the create-on-missing flow into an "unknown context" dead end.
func TestRun_CheckContextWithNewlyCreatedConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), ".kportal.yaml")
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader("y\n"), &stdout, &stderr)
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
assert.Contains(t, stdout.String(), "Configuration is valid")
}
// TestRun_CheckValidatesNonSelectedContexts proves -check did not get weaker:
// a broken forward outside the selection is still reported.
func TestRun_CheckValidatesNonSelectedContexts(t *testing.T) {
path := filepath.Join(t.TempDir(), ".kportal.yaml")
body := `contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/ok
protocol: tcp
port: 80
localPort: 8080
- name: team-b
namespaces:
- name: default
forwards:
- resource: pod/bad
protocol: tcp
port: 80
localPort: 999999
`
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader(""), &stdout, &stderr)
assert.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "localPort")
}
// ---- reloadFromSignal ----
func TestReloadFromSignal_OnAppliedRunsAfterSuccessfulReload(t *testing.T) {
mgr, err := forward.NewManager(false)
require.NoError(t, err)
t.Cleanup(mgr.Stop)
cfgPath := writeYAML(t, "r.yaml", `tui:
columns:
- name: alias
contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 38080
`)
var got *config.Config
reloadFromSignal(runOptions{configFile: cfgPath}, mgr, false, func(c *config.Config) { got = c })
require.NotNil(t, got)
require.Len(t, got.GetTableColumns(), 1)
assert.Equal(t, "alias", got.GetTableColumns()[0].Name)
}
func TestReloadFromSignal_OnAppliedSkippedWhenConfigInvalid(t *testing.T) {
mgr, err := forward.NewManager(false)
require.NoError(t, err)
cfgPath := writeYAML(t, "bad.yaml", "tui:\n columns:\n - name: bogus\ncontexts: []\n")
called := false
reloadFromSignal(runOptions{configFile: cfgPath}, mgr, false, func(*config.Config) { called = true })
assert.False(t, called)
}