diff --git a/cmd/kportal/helpers_test.go b/cmd/kportal/helpers_test.go new file mode 100644 index 0000000..7889f3e --- /dev/null +++ b/cmd/kportal/helpers_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "bufio" + "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 ---- + +// 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 + 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) { + restore := redirectStdin(t, tc.input) + defer restore() + + result := promptCreateConfig("/some/path.yaml") + 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) { + restore := redirectStdin(t, tc.input) + defer restore() + + result := promptCreateConfig("/some/path.yaml") + 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") + 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) { + // 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") +} diff --git a/internal/version/checker_http_test.go b/internal/version/checker_http_test.go new file mode 100644 index 0000000..a8a15bb --- /dev/null +++ b/internal/version/checker_http_test.go @@ -0,0 +1,279 @@ +package version + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeChecker builds a Checker whose HTTP client is wired to the given test +// server. Because fetchLatestRelease constructs its URL from owner+repo, we +// embed the server's base URL directly in the owner field so the final URL +// becomes "//releases/latest" – fine for an httptest server +// that ignores the path. +func makeCheckerWithServer(t *testing.T, srv *httptest.Server, currentVersion string) *Checker { + t.Helper() + c := NewChecker("owner", "repo", currentVersion) + // Replace the HTTP client with one whose transport rewrites every outgoing + // request to the test server, regardless of the original URL. This is + // necessary because fetchLatestRelease hard-codes the GitHub API URL, so + // we cannot influence the host via owner/repo fields. + c.client = &http.Client{ + Timeout: 5 * time.Second, + Transport: &rewriteTransport{inner: srv.Client().Transport, base: srv.URL}, + } + return c +} + +// rewriteTransport redirects every outgoing request to baseURL, preserving +// the path and query of the original request. +type rewriteTransport struct { + inner http.RoundTripper + base string +} + +func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request and rewrite the host to our test server. + r2 := req.Clone(req.Context()) + r2.URL.Scheme = "http" + // Parse just the host from base (strip scheme prefix). + host := strings.TrimPrefix(rt.base, "http://") + host = strings.TrimPrefix(host, "https://") + r2.URL.Host = host + return rt.inner.RoundTrip(r2) +} + +// TestNewChecker verifies the constructor sets fields correctly. +func TestNewChecker_FieldsSet(t *testing.T) { + c := NewChecker("myowner", "myrepo", "v1.2.3") + require.NotNil(t, c) + assert.Equal(t, "myowner", c.owner) + assert.Equal(t, "myrepo", c.repo) + assert.Equal(t, "1.2.3", c.current) // normalizeVersion strips the "v" + assert.NotNil(t, c.client) +} + +// TestNewChecker_NormalizesVersion ensures the v-prefix is stripped at construction. +func TestNewChecker_NormalizesVersion(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"v0.1.0", "0.1.0"}, + {"V2.0.0", "2.0.0"}, + {"3.0.0", "3.0.0"}, + {" v1.0.0 ", "1.0.0"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + c := NewChecker("o", "r", tc.input) + assert.Equal(t, tc.expected, c.current) + }) + } +} + +// TestCheckForUpdate_NewerVersionAvailable verifies an UpdateInfo is returned +// when the server reports a newer tag. +func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release := ReleaseInfo{ + TagName: "v2.0.0", + HTMLURL: "https://github.com/example/repo/releases/tag/v2.0.0", + Name: "Release v2.0.0", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + info := c.CheckForUpdate(context.Background()) + + require.NotNil(t, info) + assert.Equal(t, "1.0.0", info.CurrentVersion) + assert.Equal(t, "2.0.0", info.LatestVersion) + assert.Equal(t, "https://github.com/example/repo/releases/tag/v2.0.0", info.ReleaseURL) + assert.Equal(t, "Release v2.0.0", info.ReleaseName) +} + +// TestCheckForUpdate_CurrentIsLatest verifies nil is returned when already on +// the latest version. +func TestCheckForUpdate_CurrentIsLatest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release := ReleaseInfo{TagName: "v1.0.0", HTMLURL: "https://example.com"} + _ = json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + info := c.CheckForUpdate(context.Background()) + assert.Nil(t, info) +} + +// TestCheckForUpdate_CurrentIsNewer verifies nil is returned when the running +// version is ahead of the released one. +func TestCheckForUpdate_CurrentIsNewer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release := ReleaseInfo{TagName: "v0.9.0", HTMLURL: "https://example.com"} + _ = json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + info := c.CheckForUpdate(context.Background()) + assert.Nil(t, info) +} + +// TestCheckForUpdate_NetworkError verifies nil is returned on network failure +// (fail-silent contract). +func TestCheckForUpdate_NetworkError(t *testing.T) { + // Point at a server that is immediately closed. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() // close before the request is made + + c := makeCheckerWithServer(t, srv, "1.0.0") + info := c.CheckForUpdate(context.Background()) + assert.Nil(t, info, "network error should return nil (fail silent)") +} + +// TestCheckForUpdate_CancelledContext verifies nil is returned when the +// context is already cancelled. +func TestCheckForUpdate_CancelledContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release := ReleaseInfo{TagName: "v9.9.9"} + _ = json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + info := c.CheckForUpdate(ctx) + assert.Nil(t, info, "cancelled context should return nil") +} + +// TestFetchLatestRelease_NonOKStatus verifies an error is returned for non-200 +// responses (e.g. rate-limit 403, 404, 500). +func TestFetchLatestRelease_NonOKStatus(t *testing.T) { + codes := []int{http.StatusNotFound, http.StatusForbidden, http.StatusInternalServerError, http.StatusTooManyRequests} + for _, code := range codes { + code := code + t.Run(http.StatusText(code), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + release, err := c.fetchLatestRelease(context.Background()) + assert.Nil(t, release) + require.Error(t, err) + assert.Contains(t, err.Error(), "status") + }) + } +} + +// TestFetchLatestRelease_MalformedJSON verifies an error is returned when the +// response body is not valid JSON. +func TestFetchLatestRelease_MalformedJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{not valid json`)) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + release, err := c.fetchLatestRelease(context.Background()) + assert.Nil(t, release) + require.Error(t, err) +} + +// TestFetchLatestRelease_EmptyTagName verifies that a response with no tag_name +// is parsed (returns a ReleaseInfo with empty TagName) without error. +func TestFetchLatestRelease_EmptyTagName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"html_url":"https://example.com","name":"no tag"}`)) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + release, err := c.fetchLatestRelease(context.Background()) + require.NoError(t, err) + require.NotNil(t, release) + assert.Empty(t, release.TagName) + assert.Equal(t, "https://example.com", release.HTMLURL) +} + +// TestFetchLatestRelease_RequestHeaders verifies the Accept and User-Agent +// headers are set on the outgoing request. +func TestFetchLatestRelease_RequestHeaders(t *testing.T) { + var gotAccept, gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAccept = r.Header.Get("Accept") + gotUA = r.Header.Get("User-Agent") + release := ReleaseInfo{TagName: "v1.0.0"} + _ = json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "1.0.0") + _, err := c.fetchLatestRelease(context.Background()) + require.NoError(t, err) + assert.Equal(t, "application/vnd.github.v3+json", gotAccept) + assert.Equal(t, "kportal-version-checker", gotUA) +} + +// TestCheckForUpdate_WithVPrefix verifies that a tag like "v2.0.0" is +// normalised correctly before comparison. +func TestCheckForUpdate_WithVPrefix(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release := ReleaseInfo{ + TagName: "v1.1.0", + HTMLURL: "https://example.com/v1.1.0", + } + _ = json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + c := makeCheckerWithServer(t, srv, "v1.0.0") + info := c.CheckForUpdate(context.Background()) + require.NotNil(t, info) + assert.Equal(t, "1.1.0", info.LatestVersion) + assert.Equal(t, "1.0.0", info.CurrentVersion) +} + +// TestParseVersion_EdgeCases covers inputs not exercised by the existing tests. +func TestParseVersion_EdgeCases(t *testing.T) { + cases := []struct { + name string + input string + expected []int + }{ + {"empty string", "", []int{0}}, + {"single digit", "3", []int{3}}, + {"non-numeric part", "abc", []int{0}}, + {"mixed numeric and alpha", "1.abc.3", []int{1, 0, 3}}, + {"build metadata only", "1.0.0+meta", []int{1, 0, 0}}, + {"pre-release only", "1.0.0-alpha.1", []int{1, 0, 0}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := parseVersion(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +// TestIsNewerVersion_EqualLength covers the equal-length tie case. +func TestIsNewerVersion_EqualLength(t *testing.T) { + // Equal versions with same length: not newer. + assert.False(t, isNewerVersion("1.2.3", "1.2.3")) +}