From 309659fe15e0c6989d8ac0bc30bd377a8e593ec0 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:33:40 -0400 Subject: [PATCH 01/30] X --- internal/cli/commands.go | 75 ++++++++++ internal/cli/run_non_ineractive.go | 136 ++++++++++++++++++ internal/config/watcher.go | 11 +- internal/server/server.go | 3 + main.go | 216 ++--------------------------- main_test.go | 151 -------------------- 6 files changed, 235 insertions(+), 357 deletions(-) create mode 100644 internal/cli/commands.go create mode 100644 internal/cli/run_non_ineractive.go delete mode 100644 main_test.go diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 00000000..2742a189 --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,75 @@ +package cli + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/di" + "github.com/evg4b/uncors/internal/tui" + uncor "github.com/evg4b/uncors/internal/uncors_app" + "github.com/spf13/afero" + "github.com/spf13/pflag" +) + +const ( + GenerateCertsCmd = "generate-certs" + baseAddress = "127.0.0.1" +) + +func GenerateCerts(args []string) error { + print("GenerateCerts ") + + pflag.Usage = func() { + output := tui.NewCliOutput(os.Stdout) + tui.PrintLogo(output, "Version") + fmt.Fprintf(output, "Usage of %s:\n", os.Args[0]) + pflag.PrintDefaults() + } + + flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) + + flags.Parse(args) + + return nil +} + +func RunUncors(args []string) error { + fs := afero.NewOsFs() + + uncorsConfig, path, err := config.LoadConfiguration(fs, args) + if err != nil { + return err + } + + if uncorsConfig.Interactive { + return runIneractive(fs, uncorsConfig, path, args) + } + + return runNonIneractive(fs, uncorsConfig, path, args) +} + +func runIneractive(fs afero.Fs, uncorsConfig *config.UncorsConfig, configPath string, args []string) error { + container := di.NewContainer( + di.WithFs(fs), + di.WithStdout(os.Stdout), + // di.WithVersion("Version"), + ) + defer container.Close() + + app := uncor.NewUncorsApp( + container, + configPath, + uncorsConfig, + func() *config.UncorsConfig { + reloaded, _, _ := config.LoadConfiguration(container.Fs(), args) + + return reloaded + }, + ) + + _, err := tea.NewProgram(app).Run() + + return err +} diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go new file mode 100644 index 00000000..2bbd1cfd --- /dev/null +++ b/internal/cli/run_non_ineractive.go @@ -0,0 +1,136 @@ +package cli + +import ( + "context" + "errors" + "log" + "net" + "os" + "strconv" + "time" + + "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/di" + "github.com/evg4b/uncors/internal/helpers" + "github.com/evg4b/uncors/internal/server" + "github.com/evg4b/uncors/internal/tui" + "github.com/spf13/afero" +) + +func runNonIneractive(fs afero.Fs, uncorsConfig *config.UncorsConfig, configPath string, args []string) error { + container := di.NewContainer( + di.WithFs(fs), + di.WithStdout(os.Stdout), + // di.WithVersion("Version"), + ) + defer container.Close() + + output := container.CliOutput() + tui.PrintLogo(output, container.Version()) + output.Print("") + output.WarnBox(tui.DisclaimerMessage) + output.Print("") + output.InfoBox(uncorsConfig.Mappings.String()) + output.Print("") + + targets, err := mappingsToTarget(container, uncorsConfig) + if err != nil { + return err + } + + ctx := context.Background() + + server := container.Server() + + err = server.Start(ctx, targets) + if err != nil { + return err + } + + go startVersionChecker(ctx, container, uncorsConfig.Proxy) + + go func(configPath string) { + watcher := config.NewWatcher(configPath) + + err := watcher.Watch(ctx, func() { reloadServer(ctx, container, server, args) }) + if err != nil { + output.Error(err) + } + }(configPath) + + go helpers.GracefulShutdown(ctx, func(shutdownCtx context.Context) error { + log.Println("shutdown signal received") + + return server.Shutdown(shutdownCtx) + }) + + server.Wait() + output.Info("Server was stopped") + + return nil +} + +func reloadServer(ctx context.Context, container *di.Container, server *server.Server, args []string) { + output := container.CliOutput() + + newUncorsConfig, _, err := config.LoadConfiguration(container.Fs(), args) + if err != nil { + output.Error(err) + + return + } + + output.Info("Restarting server....") + + targets, err := mappingsToTarget(container, newUncorsConfig) + if err != nil { + output.Error(err) + + return + } + + err = server.Restart(ctx, targets) + if err != nil { + output.Error(err) + + return + } + + output.InfoBox( + "Server restarted", + newUncorsConfig.Mappings.String(), + ) +} + +func mappingsToTarget(container *di.Container, uncorsConfig *config.UncorsConfig) ([]server.Target, error) { + groupedMappings := uncorsConfig.Mappings.GroupByPort() + targets := make([]server.Target, 0, len(groupedMappings)) + errs := make([]error, 0, len(groupedMappings)) + + for _, group := range groupedMappings { + muxRouter, err := container.Router(group.Mappings, &uncorsConfig.CacheConfig, uncorsConfig.Proxy) + if err != nil { + errs = append(errs, err) + + continue + } + + targets = append(targets, server.Target{ + Address: net.JoinHostPort(baseAddress, strconv.Itoa(group.Port)), + Handler: muxRouter, + EnableTLS: group.Scheme == "https", + }) + } + + return targets, errors.Join(errs...) +} + +// startVersionChecker waits for a short delay then checks for a newer release. +func startVersionChecker(ctx context.Context, container *di.Container, proxy string) { + const checkDelay = 50 * time.Millisecond + + time.Sleep(checkDelay) + + container.VersionChecker(proxy). + CheckNewVersion(ctx) +} diff --git a/internal/config/watcher.go b/internal/config/watcher.go index 541aee3f..aa4f7e62 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -34,6 +34,10 @@ func (w *Watcher) Watch(ctx context.Context, onChange func()) error { return errAlreadyWatching } + if w.filePath == "" { + return nil + } + _, err := os.Stat(w.filePath) if err != nil { return fmt.Errorf("failed to watch config file '%s': %w", w.filePath, err) @@ -52,9 +56,10 @@ func (w *Watcher) Watch(ctx context.Context, onChange func()) error { err = fsWatcher.Add(dir) if err != nil { - _ = fsWatcher.Close() - - return fmt.Errorf("failed to watch config directory '%s': %w", dir, err) + return errors.Join( + fsWatcher.Close(), + fmt.Errorf("failed to watch config directory '%s': %w", dir, err), + ) } w.fsWatcher = fsWatcher diff --git a/internal/server/server.go b/internal/server/server.go index 1ef7b477..7786b9bc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -138,6 +138,9 @@ func (s *Server) Shutdown(ctx context.Context) error { } func (s *Server) Restart(ctx context.Context, targets []Target) error { + s.Add(1) + defer s.Done() + err := s.Shutdown(ctx) if err != nil { return err diff --git a/main.go b/main.go index 55a97676..7cf5ada8 100644 --- a/main.go +++ b/main.go @@ -1,228 +1,38 @@ package main import ( - "context" - "fmt" "io" "log" "os" - "time" - tea "charm.land/bubbletea/v2" - "github.com/evg4b/uncors/internal/config" - "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/internal/helpers" - "github.com/evg4b/uncors/internal/server" + "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/tui" - "github.com/evg4b/uncors/internal/uncors" - uncorsapp "github.com/evg4b/uncors/internal/uncors_app" - "github.com/spf13/afero" - "github.com/spf13/pflag" ) var Version = "v0.7.0" -const generateCertsCmd = "generate-certs" - -func main() { - exitCode := run() - os.Exit(exitCode) -} - -func run() int { - fs := afero.NewOsFs() - - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - di.WithVersion(Version), - ) - defer container.Close() - - output := container.CliOutput() - - defer helpers.PanicInterceptor(func(value any) { - output.Error(value) - log.Fatalf("Caught panic: %v", value) - }) - - if len(os.Args) > 1 && os.Args[1] == generateCertsCmd { - return runGenerateCerts(container) - } - - pflag.Usage = func() { - tui.PrintLogo(output, Version) - fmt.Fprintf(output, "Usage of %s:\n", os.Args[0]) - pflag.PrintDefaults() - } - - uncorsConfig, configPath := loadConfiguration(fs) - - if uncorsConfig.Interactive { - return runInteractive(container, configPath, uncorsConfig) - } - - return runNonInteractive(context.Background(), container, configPath, uncorsConfig) -} - -// runGenerateCerts executes the generate-certs sub-command and returns an exit code. -func runGenerateCerts(container *di.Container) int { - cmd := container.GenerateCertsCommand() - output := container.CliOutput() - - flags := pflag.NewFlagSet(generateCertsCmd, pflag.ContinueOnError) - cmd.DefineFlags(flags) - - err := flags.Parse(os.Args[2:]) - if err != nil { - output.Error(err) - log.Printf("Error: %v", err) - - return 1 - } - - err = cmd.Execute() - if err != nil { - output.Error(err) - log.Printf("Error: %v", err) - - return 1 - } - - return 0 -} - -// runNonInteractive starts the proxy in non-interactive (headless) mode and -// blocks until the server shuts down. The config file is watched for changes -// when configPath is non-empty. -func runNonInteractive( - ctx context.Context, - container *di.Container, - configPath string, - cfg *config.UncorsConfig, -) int { - output := container.CliOutput() - - app := uncors.CreateUncors(container) - - go server.RequestPrinter(container.RequestTracker(), output) - - startConfigWatcher(ctx, container, configPath, app) - - err := app.Start(ctx, cfg) - if err != nil { - panic(err) - } - - go startVersionChecker(ctx, container, cfg.Proxy) - - go helpers.GracefulShutdown(ctx, func(shutdownCtx context.Context) error { - log.Println("shutdown signal received") - - return app.Shutdown(shutdownCtx) - }) - - app.Wait() - output.Info("Server was stopped") - - return 0 -} - -// startConfigWatcher begins watching the config file and restarts the proxy on -// every change. The watcher lives for the process lifetime (not closed explicitly). -func startConfigWatcher( - ctx context.Context, - container *di.Container, - configPath string, - app *uncors.Uncors, -) { - if configPath == "" { - return - } - - output := container.CliOutput() - fs := container.Fs() - watcher := config.NewWatcher(configPath) - - err := watcher.Watch(ctx, func() { - defer helpers.PanicInterceptor(func(value any) { - log.Printf("Config reloading error: %v", value) - output.Errorf("Config reloading error: %v", value) - }) - - reloaded, _ := loadConfiguration(fs) - - restartErr := app.Restart(ctx, reloaded) - if restartErr != nil { - log.Printf("Failed to restart server: %v", restartErr) - output.Errorf("Failed to restart server: %v", restartErr) - } - }) - if err != nil { - log.Printf("Failed to start config watcher: %v", err) - output.Errorf("Failed to start config watcher: %v", err) - - return - } -} - -// startVersionChecker waits for a short delay then checks for a newer release. -func startVersionChecker(ctx context.Context, container *di.Container, proxy string) { - const checkDelay = 50 * time.Millisecond - - time.Sleep(checkDelay) - - container.VersionChecker(proxy). - CheckNewVersion(ctx) -} - -// runInteractive starts the proxy in interactive TUI mode. -func runInteractive(container *di.Container, configPath string, cfg *config.UncorsConfig) int { - app := uncorsapp.NewUncorsApp( - container, - configPath, - cfg, - func() *config.UncorsConfig { - reloaded, _ := loadConfiguration(container.Fs()) - - return reloaded - }, - ) - - _, err := tea.NewProgram(app).Run() - if err != nil { - log.Fatal(err) - } - - return 0 -} - const ( logFileName = "uncors.log" logFileFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND logFilePerm = 0o644 ) -// loadConfiguration loads and validates the configuration from CLI args and the -// config file. It panics on any error so that the PanicInterceptor in run() can -// display a human-readable message and exit cleanly. -func loadConfiguration(fs afero.Fs) (*config.UncorsConfig, string) { - uncorsConfig, configPath, err := config.LoadConfiguration(fs, os.Args) - if err != nil { - panic(err) - } +func main() { + log.SetOutput(io.Discard) + + output := tui.NewCliOutput(os.Stdout) - if uncorsConfig.Debug { - logFile, err := os.OpenFile(logFileName, logFileFlags, logFilePerm) + if len(os.Args) >= 2 && os.Args[1] == cli.GenerateCertsCmd { + err := cli.GenerateCerts(os.Args[2:]) if err != nil { - panic(fmt.Sprintf("Failed to open log file: %v", err)) + output.Error(err) } - log.SetOutput(logFile) - log.Print("Enabled debug messages") - } else { - log.SetOutput(io.Discard) + return } - return uncorsConfig, configPath + err := cli.RunUncors(os.Args[1:]) + if err != nil { + output.Error(err) + } } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 48e7d93a..00000000 --- a/main_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/testing/testutils" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// setArgs temporarily overrides os.Args and returns a restore function. -func setArgs(args []string) func() { - old := os.Args - os.Args = args - - return func() { os.Args = old } -} - -func TestLoadConfiguration(t *testing.T) { - t.Run("returns config for valid flags", func(t *testing.T) { - defer setArgs([]string{"uncors", "-f", "http://localhost:3000", "-t", "https://api.example.com"})() - - cfg, path := loadConfiguration(afero.NewMemMapFs()) - - require.NotNil(t, cfg) - assert.Empty(t, path) - assert.Len(t, cfg.Mappings, 1) - }) - - t.Run("panics when mappings are empty", func(t *testing.T) { - defer setArgs([]string{"uncors"})() - - assert.Panics(t, func() { - loadConfiguration(afero.NewMemMapFs()) - }) - }) - - t.Run("panics on invalid flags", func(t *testing.T) { - defer setArgs([]string{"uncors", "--no-such-flag"})() - - assert.Panics(t, func() { - loadConfiguration(afero.NewMemMapFs()) - }) - }) -} - -func TestRunGenerateCerts(t *testing.T) { - t.Run("generates certs and returns 0", func(t *testing.T) { - defer setArgs([]string{"uncors", generateCertsCmd})() - - container := di.NewContainer() - defer testutils.Close(t, container) - - result := runGenerateCerts(container) - - assert.Equal(t, 0, result) - }) - - t.Run("returns 1 when execute fails", func(t *testing.T) { - defer setArgs([]string{"uncors", generateCertsCmd})() - - container := di.NewContainer() - defer testutils.Close(t, container) - - _ = runGenerateCerts(container) - result := runGenerateCerts(container) - - assert.Equal(t, 1, result) - }) - - t.Run("returns 1 when flags parse fails", func(t *testing.T) { - defer setArgs([]string{"uncors", generateCertsCmd, "--no-such-flag"})() - - container := di.NewContainer() - defer testutils.Close(t, container) - - result := runGenerateCerts(container) - - assert.Equal(t, 1, result) - }) -} - -func TestLoadConfigurationWithDebug(t *testing.T) { - t.Chdir(t.TempDir()) - - defer setArgs([]string{"uncors", "-f", "http://localhost:3000", "-t", "https://api.example.com", "--debug"})() - - cfg, _ := loadConfiguration(afero.NewMemMapFs()) - - require.NotNil(t, cfg) - assert.True(t, cfg.Debug) -} - -func TestLoadConfigurationWithConfigFile(t *testing.T) { - const cfgContent = ` -mappings: - - from: http://localhost:3000 - to: https://api.example.com -` - - defer setArgs([]string{"uncors", "--config", "/config.yaml"})() - - fs := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte(cfgContent), 0o600)) - - cfg, path := loadConfiguration(fs) - - require.NotNil(t, cfg) - assert.Equal(t, "/config.yaml", path) - assert.Len(t, cfg.Mappings, 1) -} - -func TestStartVersionChecker(t *testing.T) { - t.Run("runs without panic", func(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - assert.NotPanics(t, func() { - startVersionChecker(context.Background(), container, "") - }) - }) -} - -func TestStartConfigWatcher(t *testing.T) { - t.Run("logs error for non-existent config path", func(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - assert.NotPanics(t, func() { - startConfigWatcher(context.Background(), container, "/no/such/config.yaml", nil) - }) - }) - - t.Run("creates watcher for existing config file", func(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "config.yaml") - require.NoError(t, os.WriteFile(configFile, []byte("proxy: \"\""), 0o600)) - - assert.NotPanics(t, func() { - startConfigWatcher(context.Background(), container, configFile, nil) - }) - }) -} From e94d8f1f60fd9d49a4a07d730c7aab24690794d1 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:38:27 -0400 Subject: [PATCH 02/30] refactor: use server.Server directly in UncorsApp instead of uncors.Uncors wrapper Co-Authored-By: Claude Sonnet 4.6 --- internal/uncors_app/app.go | 69 +++++++++++++++++++++--- internal/uncors_app/app_internal_test.go | 12 ++--- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/internal/uncors_app/app.go b/internal/uncors_app/app.go index ff322c15..efc50dd0 100644 --- a/internal/uncors_app/app.go +++ b/internal/uncors_app/app.go @@ -2,7 +2,10 @@ package uncorsapp import ( "context" + "errors" "log" + "net" + "strconv" "strings" "time" @@ -14,7 +17,7 @@ import ( "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/internal/helpers" "github.com/evg4b/uncors/internal/server" - "github.com/evg4b/uncors/internal/uncors" + "github.com/evg4b/uncors/internal/tui" ) const ( @@ -28,7 +31,7 @@ const ( type UncorsApp struct { keys keyMap - app *uncors.Uncors + srv *server.Server output *tuiOutput tracker server.IRequestTracker container *di.Container @@ -88,7 +91,7 @@ func NewUncorsApp( return &UncorsApp{ keys: keys, - app: uncors.CreateUncors(container), + srv: container.Server(), output: output, tracker: container.RequestTracker(), container: container, @@ -278,7 +281,7 @@ func (m *UncorsApp) handleServerStarted() tea.Cmd { newCfg := m.loadConfig() - err := m.app.Restart(m.appContext(), newCfg) + err := m.restart(m.appContext(), newCfg) if err != nil { m.output.Errorf("Failed to restart server: %v", err) } @@ -330,11 +333,22 @@ func (m *UncorsApp) handleShutdown() tea.Cmd { func (m *UncorsApp) startServerCmd() tea.Cmd { return func() tea.Msg { - err := m.app.Start(m.appContext(), m.cfg) + tui.PrintLogo(m.output, m.container.Version()) + m.output.Print("") + m.output.WarnBox(tui.DisclaimerMessage) + m.output.Print("") + m.output.InfoBox(m.cfg.Mappings.String()) + m.output.Print("") + + targets, err := m.mappingsToTargets(m.cfg) if err != nil { return serverErrMsg{err: err} } + if err = m.srv.Start(m.appContext(), targets); err != nil { + return serverErrMsg{err: err} + } + return serverStartedMsg{} } } @@ -376,7 +390,7 @@ func (m *UncorsApp) shutdownCmd() tea.Cmd { ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() - _ = m.app.Shutdown(ctx) + _ = m.srv.Shutdown(ctx) return shutdownMsg{} } @@ -390,8 +404,7 @@ func (m *UncorsApp) restartCmd() tea.Cmd { newCfg := m.loadConfig() - err := m.app.Restart(m.appContext(), newCfg) - if err != nil { + if err := m.restart(m.appContext(), newCfg); err != nil { m.output.Errorf("Failed to restart: %v", err) } @@ -399,6 +412,46 @@ func (m *UncorsApp) restartCmd() tea.Cmd { } } +func (m *UncorsApp) restart(ctx context.Context, cfg *config.UncorsConfig) error { + m.output.Info("Restarting server....") + + targets, err := m.mappingsToTargets(cfg) + if err != nil { + return err + } + + if err = m.srv.Restart(ctx, targets); err != nil { + return err + } + + m.output.InfoBox("Server restarted", cfg.Mappings.String()) + + return nil +} + +func (m *UncorsApp) mappingsToTargets(cfg *config.UncorsConfig) ([]server.Target, error) { + groupedMappings := cfg.Mappings.GroupByPort() + targets := make([]server.Target, 0, len(groupedMappings)) + errs := make([]error, 0, len(groupedMappings)) + + for _, group := range groupedMappings { + muxRouter, err := m.container.Router(group.Mappings, &cfg.CacheConfig, cfg.Proxy) + if err != nil { + errs = append(errs, err) + + continue + } + + targets = append(targets, server.Target{ + Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(group.Port)), + Handler: muxRouter, + EnableTLS: group.Scheme == "https", + }) + } + + return targets, errors.Join(errs...) +} + func (m *UncorsApp) versionCheckCmd() tea.Cmd { return func() tea.Msg { time.Sleep(versionCheckDelay) diff --git a/internal/uncors_app/app_internal_test.go b/internal/uncors_app/app_internal_test.go index 37518d9a..a3532c7f 100644 --- a/internal/uncors_app/app_internal_test.go +++ b/internal/uncors_app/app_internal_test.go @@ -52,7 +52,7 @@ func cleanupTestApp(t *testing.T, app *UncorsApp) { t.Helper() app.cancel() - err := app.app.Close() + err := app.srv.Close() require.NoError(t, err) if app.historyWidget != nil && app.historyWidget.hist != nil { @@ -292,7 +292,7 @@ func TestUncorsAppServerErrorRestartShutdownAndFormatting(t *testing.T) { assert.Equal(t, tea.Quit(), cmd()) app.cancel() - err := app.app.Close() + err := app.srv.Close() require.NoError(t, err) }) } @@ -324,7 +324,7 @@ func TestHandleServerStartedWithConfigPath(t *testing.T) { defer func() { app.cancel() - err := app.app.Close() + err := app.srv.Close() require.NoError(t, err) if app.historyWidget != nil && app.historyWidget.hist != nil { @@ -352,7 +352,7 @@ func TestHandleServerStartedWithConfigPath(t *testing.T) { defer func() { app.cancel() - err := app.app.Close() + err := app.srv.Close() require.NoError(t, err) if app.historyWidget != nil && app.historyWidget.hist != nil { @@ -414,7 +414,7 @@ func TestHandleServerStartedCallbackOnFileChange(t *testing.T) { defer func() { // Cancel context first so any in-flight Restart fails fast. - // We deliberately skip app.app.Close() here: closeAll() writes + // We deliberately skip app.srv.Close() here: closeAll() writes // app.closers concurrently with the Restart goroutine's read of // app.closers, which would be a data race. app.cancel() @@ -465,7 +465,7 @@ func TestHandleShutdownWithWatcher(t *testing.T) { assert.Equal(t, tea.Quit(), cmd()) app.cancel() - err = app.app.Close() + err = app.srv.Close() require.NoError(t, err) if app.historyWidget != nil && app.historyWidget.hist != nil { From ab3cca30d8d67c4de239c5be3d1ea5afe5db51ed Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:40:05 -0400 Subject: [PATCH 03/30] refactor: delete internal/uncors package Co-Authored-By: Claude Sonnet 4.6 --- internal/uncors/app.go | 107 ----- internal/uncors/app_test.go | 575 --------------------------- internal/uncors/handler_test.go | 681 -------------------------------- testing/integration/proxy.go | 36 +- 4 files changed, 32 insertions(+), 1367 deletions(-) delete mode 100644 internal/uncors/app.go delete mode 100644 internal/uncors/app_test.go delete mode 100644 internal/uncors/handler_test.go diff --git a/internal/uncors/app.go b/internal/uncors/app.go deleted file mode 100644 index 8b7b5bdf..00000000 --- a/internal/uncors/app.go +++ /dev/null @@ -1,107 +0,0 @@ -package uncors - -import ( - "context" - "errors" - "net" - "strconv" - - "github.com/evg4b/uncors/internal/contracts" - "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/internal/server" - "github.com/evg4b/uncors/internal/tui" - - "github.com/evg4b/uncors/internal/config" - "github.com/spf13/afero" -) - -const baseAddress = "127.0.0.1" - -type Uncors struct { - fs afero.Fs - - output contracts.Output - server *server.Server - container *di.Container -} - -func CreateUncors(container *di.Container) *Uncors { - return &Uncors{ - fs: container.Fs(), - output: container.CliOutput(), - container: container, - server: container.Server(), - } -} - -func (app *Uncors) Start(ctx context.Context, uncorsConfig *config.UncorsConfig) error { - tui.PrintLogo(app.output, app.container.Version()) - app.output.Print("") - app.output.WarnBox(tui.DisclaimerMessage) - app.output.Print("") - app.output.InfoBox(uncorsConfig.Mappings.String()) - app.output.Print("") - - targets, err := app.mappingsToTarget(uncorsConfig) - if err != nil { - return err - } - - return app.server.Start(ctx, targets) -} - -func (app *Uncors) Restart(ctx context.Context, uncorsConfig *config.UncorsConfig) error { - app.output.Info("Restarting server....") - - targets, err := app.mappingsToTarget(uncorsConfig) - if err != nil { - return err - } - - err = app.server.Restart(ctx, targets) - if err != nil { - return err - } - - app.output.InfoBox( - "Server restarted", - uncorsConfig.Mappings.String(), - ) - - return nil -} - -func (app *Uncors) Close() error { - return app.server.Close() -} - -func (app *Uncors) Wait() { - app.server.Wait() -} - -func (app *Uncors) Shutdown(ctx context.Context) error { - return app.server.Shutdown(ctx) -} - -func (app *Uncors) mappingsToTarget(uncorsConfig *config.UncorsConfig) ([]server.Target, error) { - groupedMappings := uncorsConfig.Mappings.GroupByPort() - targets := make([]server.Target, 0, len(groupedMappings)) - errs := make([]error, 0, len(groupedMappings)) - - for _, group := range groupedMappings { - muxRouter, err := app.container.Router(group.Mappings, &uncorsConfig.CacheConfig, uncorsConfig.Proxy) - if err != nil { - errs = append(errs, err) - - continue - } - - targets = append(targets, server.Target{ - Address: net.JoinHostPort(baseAddress, strconv.Itoa(group.Port)), - Handler: muxRouter, - EnableTLS: group.Scheme == "https", - }) - } - - return targets, errors.Join(errs...) -} diff --git a/internal/uncors/app_test.go b/internal/uncors/app_test.go deleted file mode 100644 index a7e2b5ec..00000000 --- a/internal/uncors/app_test.go +++ /dev/null @@ -1,575 +0,0 @@ -package uncors_test - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "testing" - "time" - - "github.com/evg4b/uncors/internal/config" - "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/internal/server" - "github.com/evg4b/uncors/internal/uncors" - "github.com/evg4b/uncors/testing/hosts" - "github.com/evg4b/uncors/testing/testutils" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const version = "1.0.0" - -func TestCreateUncors(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - assert.NotNil(t, app) -} - -func TestUncorsApp(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - fs := container.Fs() - - testResponceHeader := "# Test resrver" - hostFmt := func(host string) string { return fmt.Sprintf("\tHost: %v", host) } - methodFmt := func(method string) string { return fmt.Sprintf("\tMethod: %v", method) } - urlFmt := func(method string) string { return fmt.Sprintf("\tURL: %v", method) } - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, testResponceHeader) - fmt.Fprintln(w, methodFmt(r.Method)) - fmt.Fprintln(w, urlFmt(r.URL.String())) - fmt.Fprintln(w, hostFmt(r.Host)) - })) - defer targetServer.Close() - - homeDir, err := os.UserHomeDir() - require.NoError(t, err) - - certPath, keyPath, err := server.GenerateCA(server.CAConfig{ - Fs: fs, - ValidityDays: 10, - OutputDir: filepath.Join(homeDir, ".config", "uncors"), - }) - require.NoError(t, err) - - caCert, _, err := server.LoadCA(fs, certPath, keyPath) - require.NoError(t, err) - - pool := x509.NewCertPool() - pool.AddCert(caCert) - - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - RootCAs: pool, - ServerName: "127.0.0.1", - }, - }, - } - - port := testutils.GetFreePort(t) - - err = app.Start(t.Context(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(targetServer.URL)}, - }, - }) - require.NoError(t, err) - - defer func() { require.NoError(t, app.Close()) }() - - t.Run("proxy", func(t *testing.T) { - req, err := http.NewRequestWithContext( - t.Context(), - http.MethodGet, - hosts.Loopback.HTTPPort(port).String(), - nil, - ) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - bodyData, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - uri, err := url.Parse(targetServer.URL) - require.NoError(t, err) - - assert.Contains(t, string(bodyData), uri.Host) - assert.Contains(t, string(bodyData), methodFmt(http.MethodGet)) - }) -} - -func TestUncorsStart(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "OK") - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - - err := app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(targetServer.URL)}, - }, - }) - require.NoError(t, err) - - defer app.Close() - - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPPort(port).String(), - nil, - ) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "OK", string(body)) -} - -func TestUncorsRestart(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - server1 := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "Server 1") - })) - defer server1.Close() - - server2 := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "Server 2") - })) - defer server2.Close() - - port := testutils.GetFreePort(t) - - err := app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(server1.URL)}, - }, - }) - require.NoError(t, err) - - defer app.Close() - - req1, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPPort(port).String(), - nil, - ) - require.NoError(t, err) - resp1, err := http.DefaultClient.Do(req1) - require.NoError(t, err) - body1, err := io.ReadAll(resp1.Body) - require.NoError(t, err) - resp1.Body.Close() - assert.Equal(t, "Server 1", string(body1)) - - err = app.Restart(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(server2.URL)}, - }, - }) - require.NoError(t, err) - time.Sleep(100 * time.Millisecond) - - req2, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPPort(port).String(), - nil, - ) - require.NoError(t, err) - resp2, err := http.DefaultClient.Do(req2) - require.NoError(t, err) - body2, err := io.ReadAll(resp2.Body) - require.NoError(t, err) - resp2.Body.Close() - assert.Equal(t, "Server 2", string(body2)) -} - -func TestUncorsClose(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - err := app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(targetServer.URL)}, - }, - }) - require.NoError(t, err) - - err = app.Close() - require.NoError(t, err) - - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPPort(port).String(), - nil, - ) - require.NoError(t, err) - _, err = http.DefaultClient.Do(req) - assert.Error(t, err) -} - -func TestUncorsShutdown(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - time.Sleep(50 * time.Millisecond) - w.WriteHeader(http.StatusOK) - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - err := app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(targetServer.URL)}, - }, - }) - require.NoError(t, err) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err = app.Shutdown(ctx) - assert.NoError(t, err) -} - -func TestUncorsWait(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - err := app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(port), To: hosts.Parse(targetServer.URL)}, - }, - }) - require.NoError(t, err) - - done := make(chan bool) - - go func() { - app.Wait() - - done <- true - }() - go func() { - time.Sleep(100 * time.Millisecond) - app.Close() - }() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("Wait() did not return in time") - } -} - -func TestUncorsWithHTTPSMapping(t *testing.T) { - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) - - fs := afero.NewOsFs() - require.NoError(t, fs.MkdirAll(fakeHome, 0o755)) - - container := di.NewContainer(di.WithFs(fs)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "HTTPS OK") - })) - defer targetServer.Close() - - caDir := filepath.Join(fakeHome, ".config", "uncors") - certPath, keyPath, err := server.GenerateCA(server.CAConfig{ - Fs: fs, - ValidityDays: 10, - OutputDir: caDir, - }) - require.NoError(t, err) - caCert, _, err := server.LoadCA(fs, certPath, keyPath) - require.NoError(t, err) - - pool := x509.NewCertPool() - pool.AddCert(caCert) - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - RootCAs: pool, - ServerName: "127.0.0.1", - }, - }, - } - - port := testutils.GetFreePort(t) - err = app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPSPort(port), To: hosts.Parse(targetServer.URL)}, - }, - }) - require.NoError(t, err) - - defer app.Close() - - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPSPort(port).String(), - nil, - ) - require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "HTTPS OK", string(body)) -} - -func TestUncorsWithMixedHTTPAndHTTPS(t *testing.T) { - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) - - fs := afero.NewOsFs() - require.NoError(t, fs.MkdirAll(fakeHome, 0o755)) - - container := di.NewContainer(di.WithFs(fs)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - httpServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "HTTP") - })) - defer httpServer.Close() - - httpsServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "HTTPS") - })) - defer httpsServer.Close() - - caDir := filepath.Join(fakeHome, ".config", "uncors") - certPath, keyPath, err := server.GenerateCA(server.CAConfig{ - Fs: fs, ValidityDays: 10, OutputDir: caDir, - }) - require.NoError(t, err) - caCert, _, err := server.LoadCA(fs, certPath, keyPath) - require.NoError(t, err) - - pool := x509.NewCertPool() - pool.AddCert(caCert) - tlsClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - RootCAs: pool, - ServerName: "127.0.0.1", - }, - }, - } - - httpPort := testutils.GetFreePort(t) - httpsPort := testutils.GetFreePort(t) - - err = app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - {From: hosts.Loopback.HTTPPort(httpPort), To: hosts.Parse(httpServer.URL)}, - {From: hosts.Loopback.HTTPSPort(httpsPort), To: hosts.Parse(httpsServer.URL)}, - }, - }) - require.NoError(t, err) - - defer app.Close() - - t.Run("HTTP endpoint", func(t *testing.T) { - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPPort(httpPort).String(), - nil, - ) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, "HTTP", string(body)) - }) - - t.Run("HTTPS endpoint", func(t *testing.T) { - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - hosts.Loopback.HTTPSPort(httpsPort).String(), - nil, - ) - require.NoError(t, err) - resp, err := tlsClient.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, "HTTPS", string(body)) - }) -} - -func TestUncorsWithComplexConfiguration(t *testing.T) { - container := di.NewContainer(di.WithVersion(version)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - fs := container.Fs() - - require.NoError(t, fs.MkdirAll("/static", 0o755)) - require.NoError(t, afero.WriteFile(fs, "/static/index.html", []byte("Static"), 0o644)) - require.NoError(t, afero.WriteFile(fs, "/mock.json", []byte(`{"mocked":true}`), 0o644)) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "Proxied") - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - err := app.Start(context.Background(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse(targetServer.URL), - Statics: []config.StaticDirectory{ - {Path: "/static", Dir: "/static", Index: "index.html"}, - }, - Mocks: []config.Mock{ - { - Matcher: config.RequestMatcher{Path: "/api/mock"}, - Response: config.Response{ - Code: 200, File: "/mock.json", - }, - }, - }, - Cache: config.CacheGlobs{"/cache/*"}, - }, - }, - CacheConfig: config.CacheConfig{ - Methods: []string{"GET"}, - ExpirationTime: 1 * time.Minute, - MaxSize: 100 * 1024 * 1024, - }, - }) - require.NoError(t, err) - - defer app.Close() - - t.Run("static content", func(t *testing.T) { - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "static"), - nil, - ) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - assert.Contains(t, string(body), "Static") - }) - - t.Run("mock endpoint", func(t *testing.T) { - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "api", "mock"), - nil, - ) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - assert.JSONEq(t, `{"mocked":true}`, string(body)) - }) - - t.Run("proxied content", func(t *testing.T) { - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodGet, - testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "other"), - nil, - ) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, "Proxied", string(body)) - }) -} diff --git a/internal/uncors/handler_test.go b/internal/uncors/handler_test.go deleted file mode 100644 index 0b49c115..00000000 --- a/internal/uncors/handler_test.go +++ /dev/null @@ -1,681 +0,0 @@ -package uncors_test - -import ( - "crypto/tls" - "crypto/x509" - "fmt" - "io" - "net/http" - "path/filepath" - "testing" - "time" - - "github.com/evg4b/uncors/internal/config" - "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/internal/server" - "github.com/evg4b/uncors/internal/uncors" - "github.com/evg4b/uncors/testing/hosts" - "github.com/evg4b/uncors/testing/testutils" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHandlerWithHTTP(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Test-Header", "test-value") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Hello from target: %s %s", r.Method, r.URL.Path) //nolint:gosec // G705: test handler - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - - err := app.Start(t.Context(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse(targetServer.URL), - }, - }, - }) - require.NoError(t, err) - - defer app.Close() - - methods := []string{ - http.MethodGet, - http.MethodPost, - http.MethodPut, - http.MethodPatch, - http.MethodDelete, - http.MethodHead, - } - - for _, method := range methods { - t.Run(method, func(t *testing.T) { - url := testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "api", method) - req, err := http.NewRequestWithContext(t.Context(), method, url, nil) - require.NoError(t, err) - - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "test-value", resp.Header.Get("X-Test-Header")) - - if method != http.MethodHead { - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Contains(t, string(body), fmt.Sprintf("Hello from target: %s /api/%s", method, method)) - } - }) - } - - t.Run("OPTIONS request", func(t *testing.T) { - req, err := http.NewRequestWithContext( - t.Context(), - http.MethodOptions, - testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "/api/OPTIONS"), - nil, - ) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, resp.Header.Get("Access-Control-Allow-Origin"), "*") - }) -} - -func TestHandlerWithHTTPS(t *testing.T) { - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) - - fs := afero.NewOsFs() - require.NoError(t, fs.MkdirAll(fakeHome, 0o755)) - - container := di.NewContainer(di.WithFs(fs)) - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Test-Header", "test-value") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "HTTPS response: %s %s", r.Method, r.URL.Path) //nolint:gosec // G705: test handler - })) - defer targetServer.Close() - - caDir := filepath.Join(fakeHome, ".config", "uncors") - certPath, keyPath, err := server.GenerateCA(server.CAConfig{ - Fs: fs, - ValidityDays: 10, - OutputDir: caDir, - }) - require.NoError(t, err) - - caCert, _, err := server.LoadCA(fs, certPath, keyPath) - require.NoError(t, err) - - pool := x509.NewCertPool() - pool.AddCert(caCert) - - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - RootCAs: pool, - ServerName: "127.0.0.1", - }, - }, - } - - port := testutils.GetFreePort(t) - - err = app.Start(t.Context(), &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPSPort(port), - To: hosts.Parse(targetServer.URL), - }, - }, - }) - require.NoError(t, err) - - defer app.Close() - - methods := []string{ - http.MethodGet, - http.MethodPost, - http.MethodPut, - http.MethodPatch, - http.MethodDelete, - http.MethodHead, - } - - for _, method := range methods { - t.Run(method, func(t *testing.T) { - url := testutils.JoinPath(hosts.Loopback.HTTPSPort(port).String(), "secure", method) - req, err := http.NewRequestWithContext(t.Context(), method, url, nil) - require.NoError(t, err) - - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "test-value", resp.Header.Get("X-Test-Header")) - - if method != http.MethodHead { - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Contains(t, string(body), fmt.Sprintf("HTTPS response: %s /secure/%s", method, method)) - } - }) - } - - t.Run("OPTIONS request", func(t *testing.T) { - req, err := http.NewRequestWithContext( - t.Context(), - http.MethodOptions, - testutils.JoinPath(hosts.Loopback.HTTPSPort(port).String(), "/secure/OPTIONS"), - nil, - ) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, resp.Header.Get("Access-Control-Allow-Origin"), "*") - }) -} - -func TestHandlerWithMockMiddleware(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - mockFile := "/mock-response.json" - mockContent := `{"message":"mocked"}` - require.NoError(t, afero.WriteFile(container.Fs(), mockFile, []byte(mockContent), 0o644)) - - port := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse("http://example.com"), - Mocks: []config.Mock{ - { - Matcher: config.RequestMatcher{ - Path: "/api/mock", - }, - Response: config.Response{ - Code: http.StatusOK, - File: mockFile, - }, - }, - }, - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - url := testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "api", "mock") - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.JSONEq(t, mockContent, string(body)) -} - -func TestHandlerWithStaticMiddleware(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - fs := container.Fs() - - staticDir := "/static" - indexFile := filepath.Join(staticDir, "index.html") - textFile := filepath.Join(staticDir, "test.txt") - - require.NoError(t, fs.MkdirAll(staticDir, 0o755)) - require.NoError(t, afero.WriteFile(fs, indexFile, []byte("Static Content"), 0o644)) - require.NoError(t, afero.WriteFile(fs, textFile, []byte("test file content"), 0o644)) - - port := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse("http://example.com"), - Statics: []config.StaticDirectory{ - { - Path: staticDir, - Dir: staticDir, - Index: "index.html", - }, - }, - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - t.Run("serve index file", func(t *testing.T) { - url := testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "static", "/") - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Contains(t, string(body), "Static Content") - }) - - t.Run("serve specific file", func(t *testing.T) { - url := testutils.JoinPath(hosts.Loopback.HTTPPort(port).String(), "static", "test.txt") - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "test file content", string(body)) - }) -} - -func TestHandlerWithCache(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - callCount := 0 - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - callCount++ - - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Response #%d", callCount) - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse(targetServer.URL), - Cache: config.CacheGlobs{ - "/cached/*", - }, - }, - }, - CacheConfig: config.CacheConfig{ - Methods: []string{http.MethodGet}, - ExpirationTime: time.Minute, - MaxSize: 100 * 1024 * 1024, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - client := http.DefaultClient - baseURL := hosts.Loopback.HTTPPort(port).String() - - t.Run("first request", func(t *testing.T) { - url := testutils.JoinPath(baseURL, "cached", "test") - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Contains(t, string(body), "Response #1") - }) - - t.Run("cached request", func(t *testing.T) { - url := testutils.JoinPath(baseURL, "cached", "test") - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Contains(t, string(body), "Response #1") - assert.Equal(t, 1, callCount, "should use cached response") - }) - - t.Run("non-cached path", func(t *testing.T) { - url := testutils.JoinPath(baseURL, "other", "path") - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Contains(t, string(body), "Response #2") - assert.Equal(t, 2, callCount, "should not use cache for different path") - }) -} - -func TestHandlerWithMultipleMappings(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - server1 := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "Server 1") - })) - defer server1.Close() - - server2 := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "Server 2") - })) - defer server2.Close() - - port1 := testutils.GetFreePort(t) - port2 := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port1), - To: hosts.Parse(server1.URL), - }, - { - From: hosts.Loopback.HTTPPort(port2), - To: hosts.Parse(server2.URL), - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - client := http.DefaultClient - - t.Run("mapping 1", func(t *testing.T) { - url := hosts.Loopback.HTTPPort(port1).String() - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Equal(t, "Server 1", string(body)) - }) - - t.Run("mapping 2", func(t *testing.T) { - url := hosts.Loopback.HTTPPort(port2).String() - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Equal(t, "Server 2", string(body)) - }) -} - -func TestHandlerWithRewrite(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Path: %s, Host: %s", r.URL.Path, r.Host) //nolint:gosec // G705: test handler - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse(targetServer.URL), - Rewrites: []config.RewritingOption{ - { - From: targetServer.URL, - To: hosts.Loopback.HTTPPort(port).String(), - }, - }, - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - client := http.DefaultClient - url := hosts.Loopback.HTTPPort(port).String() + "/test" - - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Contains(t, string(body), "/test") - assert.Equal(t, http.StatusOK, resp.StatusCode) -} - -func TestHandlerWithRewritePath(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Path: %s", r.URL.Path) //nolint:gosec // G705: test handler - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse(targetServer.URL), - Rewrites: []config.RewritingOption{ - { - From: "/api/v1", - To: "/api/v2", - }, - }, - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - req, err := http.NewRequestWithContext( - t.Context(), - http.MethodGet, - hosts.Loopback.HTTPPort(port).String()+"/api/v1", - nil, - ) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, string(body), "/api/v2") -} - -func TestHandlerWithOptions(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - targetServer := testutils.NewServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer targetServer.Close() - - port := testutils.GetFreePort(t) - - customHeaders := map[string]string{ - "X-Custom-Header": "custom-value", - } - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse(targetServer.URL), - OptionsHandling: config.OptionsHandling{ - Code: http.StatusNoContent, - Headers: customHeaders, - }, - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - url := hosts.Loopback.HTTPPort(port).String() + "/test" - client := &http.Client{} - - req, err := http.NewRequestWithContext(t.Context(), http.MethodOptions, url, nil) - require.NoError(t, err) - - resp, err := client.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusNoContent, resp.StatusCode) - assert.Equal(t, "custom-value", resp.Header.Get("X-Custom-Header")) -} - -func TestHandlerWithScript(t *testing.T) { - container := di.NewContainer() - defer testutils.Close(t, container) - - app := uncors.CreateUncors(container) - - port := testutils.GetFreePort(t) - - cfg := &config.UncorsConfig{ - Mappings: []config.Mapping{ - { - From: hosts.Loopback.HTTPPort(port), - To: hosts.Parse("http://example.com"), - Scripts: config.Scripts{ - { - Matcher: config.RequestMatcher{ - Path: "/script", - }, - Script: `response:WriteHeader(201)`, - }, - }, - }, - }, - } - - require.NoError(t, app.Start(t.Context(), cfg)) - defer app.Close() - - reqURL := hosts.Loopback.HTTPPort(port).String() + "/script" - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, reqURL, nil) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) -} diff --git a/testing/integration/proxy.go b/testing/integration/proxy.go index d28a0307..2ff754d1 100644 --- a/testing/integration/proxy.go +++ b/testing/integration/proxy.go @@ -4,12 +4,14 @@ package integration import ( "crypto/x509" + "errors" + "net" + "strconv" "testing" "github.com/evg4b/uncors/internal/config" "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/internal/server" - "github.com/evg4b/uncors/internal/uncors" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) @@ -39,15 +41,41 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif container := di.NewContainer(di.WithFs(fs)) - app := uncors.CreateUncors(container) + targets, err := mappingsToTargets(container, cfg) + require.NoError(t, err) + + srv := container.Server() - err = app.Start(t.Context(), cfg) + err = srv.Start(t.Context(), targets) require.NoError(t, err) t.Cleanup(func() { - _ = app.Close() + _ = srv.Close() _ = container.Close() }) return caCert } + +func mappingsToTargets(container *di.Container, cfg *config.UncorsConfig) ([]server.Target, error) { + groupedMappings := cfg.Mappings.GroupByPort() + targets := make([]server.Target, 0, len(groupedMappings)) + errs := make([]error, 0, len(groupedMappings)) + + for _, group := range groupedMappings { + muxRouter, err := container.Router(group.Mappings, &cfg.CacheConfig, cfg.Proxy) + if err != nil { + errs = append(errs, err) + + continue + } + + targets = append(targets, server.Target{ + Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(group.Port)), + Handler: muxRouter, + EnableTLS: group.Scheme == "https", + }) + } + + return targets, errors.Join(errs...) +} From eadf07640c89133b57defa2001c7f9271bc74c33 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:42:02 -0400 Subject: [PATCH 04/30] refactor: deduplicate mappingsToTargets by moving it to di.Container.Targets Co-Authored-By: Claude Sonnet 4.6 --- internal/di/public_api.go | 26 ++++++++++++++++++++++++++ internal/uncors_app/app.go | 29 ++--------------------------- testing/integration/proxy.go | 28 +--------------------------- 3 files changed, 29 insertions(+), 54 deletions(-) diff --git a/internal/di/public_api.go b/internal/di/public_api.go index 0571a7c4..223b7998 100644 --- a/internal/di/public_api.go +++ b/internal/di/public_api.go @@ -1,7 +1,10 @@ package di import ( + "errors" "io" + "net" + "strconv" "time" "github.com/evg4b/uncors/internal/commands" @@ -161,3 +164,26 @@ func (c *Container) Router( return infra.CastToContractsHandler(router), err } + +func (c *Container) Targets(cfg *config.UncorsConfig) ([]server.Target, error) { + groupedMappings := cfg.Mappings.GroupByPort() + targets := make([]server.Target, 0, len(groupedMappings)) + errs := make([]error, 0, len(groupedMappings)) + + for _, group := range groupedMappings { + muxRouter, err := c.Router(group.Mappings, &cfg.CacheConfig, cfg.Proxy) + if err != nil { + errs = append(errs, err) + + continue + } + + targets = append(targets, server.Target{ + Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(group.Port)), + Handler: muxRouter, + EnableTLS: group.Scheme == "https", + }) + } + + return targets, errors.Join(errs...) +} diff --git a/internal/uncors_app/app.go b/internal/uncors_app/app.go index efc50dd0..823e4d15 100644 --- a/internal/uncors_app/app.go +++ b/internal/uncors_app/app.go @@ -2,10 +2,7 @@ package uncorsapp import ( "context" - "errors" "log" - "net" - "strconv" "strings" "time" @@ -340,7 +337,7 @@ func (m *UncorsApp) startServerCmd() tea.Cmd { m.output.InfoBox(m.cfg.Mappings.String()) m.output.Print("") - targets, err := m.mappingsToTargets(m.cfg) + targets, err := m.container.Targets(m.cfg) if err != nil { return serverErrMsg{err: err} } @@ -415,7 +412,7 @@ func (m *UncorsApp) restartCmd() tea.Cmd { func (m *UncorsApp) restart(ctx context.Context, cfg *config.UncorsConfig) error { m.output.Info("Restarting server....") - targets, err := m.mappingsToTargets(cfg) + targets, err := m.container.Targets(cfg) if err != nil { return err } @@ -429,28 +426,6 @@ func (m *UncorsApp) restart(ctx context.Context, cfg *config.UncorsConfig) error return nil } -func (m *UncorsApp) mappingsToTargets(cfg *config.UncorsConfig) ([]server.Target, error) { - groupedMappings := cfg.Mappings.GroupByPort() - targets := make([]server.Target, 0, len(groupedMappings)) - errs := make([]error, 0, len(groupedMappings)) - - for _, group := range groupedMappings { - muxRouter, err := m.container.Router(group.Mappings, &cfg.CacheConfig, cfg.Proxy) - if err != nil { - errs = append(errs, err) - - continue - } - - targets = append(targets, server.Target{ - Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(group.Port)), - Handler: muxRouter, - EnableTLS: group.Scheme == "https", - }) - } - - return targets, errors.Join(errs...) -} func (m *UncorsApp) versionCheckCmd() tea.Cmd { return func() tea.Msg { diff --git a/testing/integration/proxy.go b/testing/integration/proxy.go index 2ff754d1..abe87ff6 100644 --- a/testing/integration/proxy.go +++ b/testing/integration/proxy.go @@ -4,9 +4,6 @@ package integration import ( "crypto/x509" - "errors" - "net" - "strconv" "testing" "github.com/evg4b/uncors/internal/config" @@ -41,7 +38,7 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif container := di.NewContainer(di.WithFs(fs)) - targets, err := mappingsToTargets(container, cfg) + targets, err := container.Targets(cfg) require.NoError(t, err) srv := container.Server() @@ -56,26 +53,3 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif return caCert } - -func mappingsToTargets(container *di.Container, cfg *config.UncorsConfig) ([]server.Target, error) { - groupedMappings := cfg.Mappings.GroupByPort() - targets := make([]server.Target, 0, len(groupedMappings)) - errs := make([]error, 0, len(groupedMappings)) - - for _, group := range groupedMappings { - muxRouter, err := container.Router(group.Mappings, &cfg.CacheConfig, cfg.Proxy) - if err != nil { - errs = append(errs, err) - - continue - } - - targets = append(targets, server.Target{ - Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(group.Port)), - Handler: muxRouter, - EnableTLS: group.Scheme == "https", - }) - } - - return targets, errors.Join(errs...) -} From ec1f2b991aa6bc8128dbb1a478bc7bd718b8b79a Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:43:19 -0400 Subject: [PATCH 05/30] feat: enable debug logging via UNCORS_LOGGING env variable Co-Authored-By: Claude Sonnet 4.6 --- main.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 7cf5ada8..5548a209 100644 --- a/main.go +++ b/main.go @@ -12,13 +12,30 @@ import ( var Version = "v0.7.0" const ( - logFileName = "uncors.log" logFileFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND logFilePerm = 0o644 ) +func setupLogging() { + path := os.Getenv("UNCORS_LOGGING") + if path == "" { + log.SetOutput(io.Discard) + + return + } + + f, err := os.OpenFile(path, logFileFlags, logFilePerm) + if err != nil { + log.SetOutput(io.Discard) + + return + } + + log.SetOutput(f) +} + func main() { - log.SetOutput(io.Discard) + setupLogging() output := tui.NewCliOutput(os.Stdout) From 78e6ac1280966dcdead6de46ae4ff033e7a04db1 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:44:57 -0400 Subject: [PATCH 06/30] fix: resolve linter warnings in main, cli, and uncors_app Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/commands.go | 6 +----- internal/uncors_app/app.go | 10 ++++++---- main.go | 5 +++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 2742a189..8afd5417 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -19,8 +19,6 @@ const ( ) func GenerateCerts(args []string) error { - print("GenerateCerts ") - pflag.Usage = func() { output := tui.NewCliOutput(os.Stdout) tui.PrintLogo(output, "Version") @@ -30,9 +28,7 @@ func GenerateCerts(args []string) error { flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) - flags.Parse(args) - - return nil + return flags.Parse(args) } func RunUncors(args []string) error { diff --git a/internal/uncors_app/app.go b/internal/uncors_app/app.go index 823e4d15..ec75e30f 100644 --- a/internal/uncors_app/app.go +++ b/internal/uncors_app/app.go @@ -342,7 +342,8 @@ func (m *UncorsApp) startServerCmd() tea.Cmd { return serverErrMsg{err: err} } - if err = m.srv.Start(m.appContext(), targets); err != nil { + err = m.srv.Start(m.appContext(), targets) + if err != nil { return serverErrMsg{err: err} } @@ -401,7 +402,8 @@ func (m *UncorsApp) restartCmd() tea.Cmd { newCfg := m.loadConfig() - if err := m.restart(m.appContext(), newCfg); err != nil { + err := m.restart(m.appContext(), newCfg) + if err != nil { m.output.Errorf("Failed to restart: %v", err) } @@ -417,7 +419,8 @@ func (m *UncorsApp) restart(ctx context.Context, cfg *config.UncorsConfig) error return err } - if err = m.srv.Restart(ctx, targets); err != nil { + err = m.srv.Restart(ctx, targets) + if err != nil { return err } @@ -426,7 +429,6 @@ func (m *UncorsApp) restart(ctx context.Context, cfg *config.UncorsConfig) error return nil } - func (m *UncorsApp) versionCheckCmd() tea.Cmd { return func() tea.Msg { time.Sleep(versionCheckDelay) diff --git a/main.go b/main.go index 5548a209..83b44a76 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "io" "log" "os" + "path/filepath" "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/tui" @@ -24,14 +25,14 @@ func setupLogging() { return } - f, err := os.OpenFile(path, logFileFlags, logFilePerm) + logFile, err := os.OpenFile(filepath.Clean(path), logFileFlags, logFilePerm) if err != nil { log.SetOutput(io.Discard) return } - log.SetOutput(f) + log.SetOutput(logFile) } func main() { From 9aaf2853d81802ffd5a0f0499f623e0f88eeb193 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 22 Jun 2026 23:47:57 -0400 Subject: [PATCH 07/30] fix: remove self-import cycle in cli package Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/commands.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 8afd5417..5f3f8ee1 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1,13 +1,11 @@ package cli import ( - "fmt" "os" tea "charm.land/bubbletea/v2" "github.com/evg4b/uncors/internal/config" "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/internal/tui" uncor "github.com/evg4b/uncors/internal/uncors_app" "github.com/spf13/afero" "github.com/spf13/pflag" @@ -19,16 +17,26 @@ const ( ) func GenerateCerts(args []string) error { - pflag.Usage = func() { - output := tui.NewCliOutput(os.Stdout) - tui.PrintLogo(output, "Version") - fmt.Fprintf(output, "Usage of %s:\n", os.Args[0]) - pflag.PrintDefaults() - } + fs := afero.NewOsFs() + + container := di.NewContainer( + di.WithFs(fs), + di.WithStdout(os.Stdout), + // di.WithVersion("Version"), + ) + defer container.Close() + + cmd := container.GenerateCertsCommand() flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) + cmd.DefineFlags(flags) + + err := flags.Parse(args) + if err != nil { + return err + } - return flags.Parse(args) + return cmd.Execute() } func RunUncors(args []string) error { From 1d7e5349c273dc9b3bcb400d1855e5dbc9653cf8 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 00:06:13 -0400 Subject: [PATCH 08/30] refactor: use cli.RunUncors in integration tests, add ctx to RunUncors - RunUncors and runNonIneractive now accept a context.Context so callers (including tests) can control server lifetime via cancellation - Shutdown goroutine handles both OS signals and ctx.Done, ensuring the server stops cleanly in either case - Removed duplicate mappingsToTarget from run_non_ineractive.go; now uses container.Targets(cfg) consistently - bootProxy rewrites to serialize the config to YAML, call cli.RunUncors in a goroutine with t.Context(), and poll mapped ports for readiness Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/commands.go | 12 ++--- internal/cli/run_non_ineractive.go | 85 ++++++++++++++---------------- main.go | 4 +- testing/integration/proxy.go | 59 +++++++++++++++++---- 4 files changed, 97 insertions(+), 63 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 5f3f8ee1..cb55f0ab 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1,6 +1,7 @@ package cli import ( + "context" "os" tea "charm.land/bubbletea/v2" @@ -11,10 +12,7 @@ import ( "github.com/spf13/pflag" ) -const ( - GenerateCertsCmd = "generate-certs" - baseAddress = "127.0.0.1" -) +const GenerateCertsCmd = "generate-certs" func GenerateCerts(args []string) error { fs := afero.NewOsFs() @@ -39,9 +37,7 @@ func GenerateCerts(args []string) error { return cmd.Execute() } -func RunUncors(args []string) error { - fs := afero.NewOsFs() - +func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { uncorsConfig, path, err := config.LoadConfiguration(fs, args) if err != nil { return err @@ -51,7 +47,7 @@ func RunUncors(args []string) error { return runIneractive(fs, uncorsConfig, path, args) } - return runNonIneractive(fs, uncorsConfig, path, args) + return runNonIneractive(ctx, fs, uncorsConfig, path, args) } func runIneractive(fs afero.Fs, uncorsConfig *config.UncorsConfig, configPath string, args []string) error { diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go index 2bbd1cfd..cff328ee 100644 --- a/internal/cli/run_non_ineractive.go +++ b/internal/cli/run_non_ineractive.go @@ -2,22 +2,28 @@ package cli import ( "context" - "errors" "log" - "net" "os" - "strconv" + "os/signal" + "syscall" "time" "github.com/evg4b/uncors/internal/config" "github.com/evg4b/uncors/internal/di" - "github.com/evg4b/uncors/internal/helpers" "github.com/evg4b/uncors/internal/server" "github.com/evg4b/uncors/internal/tui" "github.com/spf13/afero" ) -func runNonIneractive(fs afero.Fs, uncorsConfig *config.UncorsConfig, configPath string, args []string) error { +const shutdownTimeout = 15 * time.Second + +func runNonIneractive( + ctx context.Context, + fs afero.Fs, + uncorsConfig *config.UncorsConfig, + configPath string, + args []string, +) error { container := di.NewContainer( di.WithFs(fs), di.WithStdout(os.Stdout), @@ -33,44 +39,58 @@ func runNonIneractive(fs afero.Fs, uncorsConfig *config.UncorsConfig, configPath output.InfoBox(uncorsConfig.Mappings.String()) output.Print("") - targets, err := mappingsToTarget(container, uncorsConfig) + targets, err := container.Targets(uncorsConfig) if err != nil { return err } - ctx := context.Background() - - server := container.Server() + srv := container.Server() - err = server.Start(ctx, targets) + err = srv.Start(ctx, targets) if err != nil { return err } go startVersionChecker(ctx, container, uncorsConfig.Proxy) - go func(configPath string) { + go func() { watcher := config.NewWatcher(configPath) - err := watcher.Watch(ctx, func() { reloadServer(ctx, container, server, args) }) + err := watcher.Watch(ctx, func() { reloadServer(ctx, container, srv, args) }) if err != nil { output.Error(err) } - }(configPath) + }() + + go func() { //nolint:gosec // G118: shutdown needs a fresh context because parent ctx is being cancelled + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + defer signal.Stop(stop) + + select { + case sig := <-stop: + if sig == syscall.SIGINT { + _, _ = os.Stdout.WriteString("\n") + } + + log.Println("shutdown signal received") + case <-ctx.Done(): + } - go helpers.GracefulShutdown(ctx, func(shutdownCtx context.Context) error { - log.Println("shutdown signal received") + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() - return server.Shutdown(shutdownCtx) - }) + _ = srv.Shutdown(shutdownCtx) + }() - server.Wait() + srv.Wait() output.Info("Server was stopped") return nil } -func reloadServer(ctx context.Context, container *di.Container, server *server.Server, args []string) { +func reloadServer(ctx context.Context, container *di.Container, srv *server.Server, args []string) { output := container.CliOutput() newUncorsConfig, _, err := config.LoadConfiguration(container.Fs(), args) @@ -82,14 +102,14 @@ func reloadServer(ctx context.Context, container *di.Container, server *server.S output.Info("Restarting server....") - targets, err := mappingsToTarget(container, newUncorsConfig) + targets, err := container.Targets(newUncorsConfig) if err != nil { output.Error(err) return } - err = server.Restart(ctx, targets) + err = srv.Restart(ctx, targets) if err != nil { output.Error(err) @@ -102,29 +122,6 @@ func reloadServer(ctx context.Context, container *di.Container, server *server.S ) } -func mappingsToTarget(container *di.Container, uncorsConfig *config.UncorsConfig) ([]server.Target, error) { - groupedMappings := uncorsConfig.Mappings.GroupByPort() - targets := make([]server.Target, 0, len(groupedMappings)) - errs := make([]error, 0, len(groupedMappings)) - - for _, group := range groupedMappings { - muxRouter, err := container.Router(group.Mappings, &uncorsConfig.CacheConfig, uncorsConfig.Proxy) - if err != nil { - errs = append(errs, err) - - continue - } - - targets = append(targets, server.Target{ - Address: net.JoinHostPort(baseAddress, strconv.Itoa(group.Port)), - Handler: muxRouter, - EnableTLS: group.Scheme == "https", - }) - } - - return targets, errors.Join(errs...) -} - // startVersionChecker waits for a short delay then checks for a newer release. func startVersionChecker(ctx context.Context, container *di.Container, proxy string) { const checkDelay = 50 * time.Millisecond diff --git a/main.go b/main.go index 83b44a76..ade9800f 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "io" "log" "os" @@ -8,6 +9,7 @@ import ( "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/tui" + "github.com/spf13/afero" ) var Version = "v0.7.0" @@ -49,7 +51,7 @@ func main() { return } - err := cli.RunUncors(os.Args[1:]) + err := cli.RunUncors(context.Background(), afero.NewOsFs(), os.Args[1:]) if err != nil { output.Error(err) } diff --git a/testing/integration/proxy.go b/testing/integration/proxy.go index abe87ff6..bf42a61b 100644 --- a/testing/integration/proxy.go +++ b/testing/integration/proxy.go @@ -4,13 +4,17 @@ package integration import ( "crypto/x509" + "net" + "strconv" "testing" + "time" + "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/config" - "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/internal/server" "github.com/spf13/afero" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) // caValidityDays must stay clear of the proxy's expiration warning threshold @@ -36,20 +40,55 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif caCert, _, err := server.LoadCA(fs, certPath, keyPath) require.NoError(t, err) - container := di.NewContainer(di.WithFs(fs)) - - targets, err := container.Targets(cfg) + data, err := yaml.Marshal(cfg) require.NoError(t, err) - srv := container.Server() + const configPath = "/uncors-config.yaml" - err = srv.Start(t.Context(), targets) + err = afero.WriteFile(fs, configPath, data, 0o644) require.NoError(t, err) - t.Cleanup(func() { - _ = srv.Close() - _ = container.Close() - }) + go func() { + _ = cli.RunUncors(t.Context(), fs, []string{"-c", configPath}) + }() + + waitForMappings(t, cfg) return caCert } + +// waitForMappings polls until every mapped port is accepting TCP connections. +func waitForMappings(t *testing.T, cfg *config.UncorsConfig) { + t.Helper() + + const ( + readyTimeout = 5 * time.Second + pollInterval = 25 * time.Millisecond + dialTimeout = 100 * time.Millisecond + ) + + for _, m := range cfg.Mappings { + if m.From.Port == "" { + continue + } + + addr := net.JoinHostPort("127.0.0.1", m.From.Port) + deadline := time.Now().Add(readyTimeout) + + for time.Now().Before(deadline) { + conn, dialErr := net.DialTimeout("tcp", addr, dialTimeout) + if dialErr == nil { + conn.Close() + + break + } + + time.Sleep(pollInterval) + } + + if time.Now().After(deadline) { + port, _ := strconv.Atoi(m.From.Port) + t.Fatalf("proxy port %d did not become ready within %s", port, readyTimeout) + } + } +} From 80178cd432a7f8428bc2e0bed7b2821318fcb7b5 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 00:11:07 -0400 Subject: [PATCH 09/30] fix: pass --interactive=false in bootProxy and fix proxy.go lint issues - RunUncors defaults to interactive mode (defaultConfig sets Interactive:true); bootProxy must override with --interactive=false so the proxy starts its TCP listeners instead of launching the TUI - Fix gofmt alignment in const block - Replace 0o644 magic number with named constant configFilePerm - Replace net.DialTimeout with (*net.Dialer).DialContext (noctx linter) - Rename loop variable m -> mapping (varnamelen linter) Co-Authored-By: Claude Sonnet 4.6 --- testing/integration/proxy.go | 39 ++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/testing/integration/proxy.go b/testing/integration/proxy.go index bf42a61b..89430141 100644 --- a/testing/integration/proxy.go +++ b/testing/integration/proxy.go @@ -3,6 +3,7 @@ package integration import ( + "context" "crypto/x509" "net" "strconv" @@ -19,7 +20,13 @@ import ( // caValidityDays must stay clear of the proxy's expiration warning threshold // (7 days); below it HostCertManager refuses to serve TLS. -const caValidityDays = 30 +const ( + caValidityDays = 30 + configFilePerm = 0o600 + proxyReadyWait = 5 * time.Second + proxyPollTick = 25 * time.Millisecond + proxyDialTimeout = 100 * time.Millisecond +) // bootProxy generates a fresh dev CA, starts uncors in-process with the given // config, and registers shutdown with t.Cleanup. Returns the CA that the client @@ -45,11 +52,13 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif const configPath = "/uncors-config.yaml" - err = afero.WriteFile(fs, configPath, data, 0o644) + err = afero.WriteFile(fs, configPath, data, configFilePerm) require.NoError(t, err) go func() { - _ = cli.RunUncors(t.Context(), fs, []string{"-c", configPath}) + // --interactive=false overrides the default (true) so the proxy runs + // in headless mode and actually starts its TCP listeners. + _ = cli.RunUncors(t.Context(), fs, []string{"-c", configPath, "--interactive=false"}) }() waitForMappings(t, cfg) @@ -61,34 +70,30 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif func waitForMappings(t *testing.T, cfg *config.UncorsConfig) { t.Helper() - const ( - readyTimeout = 5 * time.Second - pollInterval = 25 * time.Millisecond - dialTimeout = 100 * time.Millisecond - ) - - for _, m := range cfg.Mappings { - if m.From.Port == "" { + for _, mapping := range cfg.Mappings { + if mapping.From.Port == "" { continue } - addr := net.JoinHostPort("127.0.0.1", m.From.Port) - deadline := time.Now().Add(readyTimeout) + addr := net.JoinHostPort("127.0.0.1", mapping.From.Port) + deadline := time.Now().Add(proxyReadyWait) for time.Now().Before(deadline) { - conn, dialErr := net.DialTimeout("tcp", addr, dialTimeout) + dialer := &net.Dialer{Timeout: proxyDialTimeout} + + conn, dialErr := dialer.DialContext(context.Background(), "tcp", addr) if dialErr == nil { conn.Close() break } - time.Sleep(pollInterval) + time.Sleep(proxyPollTick) } if time.Now().After(deadline) { - port, _ := strconv.Atoi(m.From.Port) - t.Fatalf("proxy port %d did not become ready within %s", port, readyTimeout) + port, _ := strconv.Atoi(mapping.From.Port) + t.Fatalf("proxy port %d did not become ready within %s", port, proxyReadyWait) } } } From 6ff01970f5f23eeedc0a5fdb2f88094b9ff76534 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 00:19:28 -0400 Subject: [PATCH 10/30] fix: add omitempty to CacheConfig yaml fields yaml.Marshal serializes zero-value CacheConfig fields (expiration-time: 0s, max-size: 0, methods: []), which override defaultConfig() when the YAML is reloaded, causing integration test boot to fail with CacheConfig validation errors. omitempty on individual fields ensures zero values are omitted from the serialized YAML, preserving the defaults on reload. Co-Authored-By: Claude Sonnet 4.6 --- internal/config/cache_config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/config/cache_config.go b/internal/config/cache_config.go index f067c437..4d418a6b 100644 --- a/internal/config/cache_config.go +++ b/internal/config/cache_config.go @@ -14,9 +14,9 @@ func (g CacheGlobs) Clone() CacheGlobs { } type CacheConfig struct { - ExpirationTime time.Duration `yaml:"expiration-time"` - MaxSize int64 `yaml:"max-size"` - Methods []string `yaml:"methods"` + ExpirationTime time.Duration `yaml:"expiration-time,omitempty"` + MaxSize int64 `yaml:"max-size,omitempty"` + Methods []string `yaml:"methods,omitempty"` } func (c *CacheConfig) Clone() *CacheConfig { From 063cc0fa5c601e37239256798f620a9e2d4b4233 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 00:41:03 -0400 Subject: [PATCH 11/30] test: add coverage for cli, di.Targets, and main to meet SonarCloud gate Adds tests for RunUncors (load error, startup/shutdown, port-in-use, config reload success and error paths) and GenerateCerts, covering the new internal/cli package. Extends di public_api_test with TestContainerTargets (HTTP, HTTPS, port grouping). Adds main_test.go covering setupLogging and main() code paths. Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/commands_test.go | 216 +++++++++++++++++++++++++++++++++ internal/di/public_api_test.go | 87 +++++++++++++ main_test.go | 94 ++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 internal/cli/commands_test.go create mode 100644 main_test.go diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 00000000..5b9e5641 --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,216 @@ +package cli_test + +import ( + "context" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/evg4b/uncors/internal/cli" + "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/testing/hosts" + "github.com/evg4b/uncors/testing/testutils" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// httpMapping builds a minimal valid UncorsConfig for an HTTP proxy on a free port. +func httpMapping(t *testing.T) (*config.UncorsConfig, int) { + t.Helper() + + port := testutils.GetFreePort(t) + + cfg := &config.UncorsConfig{ + Mappings: config.Mappings{{ + From: hosts.Localhost.HTTPPort(port), + To: hosts.Localhost.HTTP(), + }}, + CacheConfig: config.CacheConfig{ + ExpirationTime: config.DefaultExpirationTime, + MaxSize: config.DefaultMaxSize, + Methods: []string{http.MethodGet}, + }, + } + + return cfg, port +} + +// waitForPort blocks until the TCP address accepts connections or times out. +func waitForPort(t *testing.T, addr string) { + t.Helper() + + const ( + dialTimeout = 100 * time.Millisecond + pollTick = 25 * time.Millisecond + readyWait = 5 * time.Second + ) + + deadline := time.Now().Add(readyWait) + + for time.Now().Before(deadline) { + dialer := &net.Dialer{Timeout: dialTimeout} + + conn, err := dialer.DialContext(context.Background(), "tcp", addr) + if err == nil { + conn.Close() + + return + } + + time.Sleep(pollTick) + } + + t.Fatal("port did not become ready within 5s: " + addr) +} + +// writeConfig marshals cfg to YAML and writes it to path on the real OS filesystem. +func writeConfig(t *testing.T, path string, cfg *config.UncorsConfig) { + t.Helper() + + data, err := yaml.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) +} + +// startProxy starts RunUncors in a goroutine and returns a channel that +// receives the error when it exits. The caller must cancel the context and +// drain the channel to ensure the goroutine has fully stopped. +func startProxy(ctx context.Context, fs afero.Fs, args []string) <-chan error { + errCh := make(chan error, 1) + + go func() { + errCh <- cli.RunUncors(ctx, fs, args) + }() + + return errCh +} + +func TestRunUncors(t *testing.T) { + t.Run("returns error when LoadConfiguration fails", func(t *testing.T) { + // No --from/--to flags and no config file → "mappings must not be empty" + err := cli.RunUncors(context.Background(), afero.NewMemMapFs(), []string{}) + require.Error(t, err) + }) + + t.Run("non-interactive: starts server and shuts down on context cancellation", func(t *testing.T) { + cfg, port := httpMapping(t) + fs := afero.NewMemMapFs() + + data, err := yaml.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, afero.WriteFile(fs, "/config.yaml", data, 0o600)) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := startProxy(ctx, fs, []string{"-c", "/config.yaml", "--interactive=false"}) + + waitForPort(t, net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + cancel() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("RunUncors did not exit after context cancellation") + } + }) + + t.Run("non-interactive: returns error when port is already in use", func(t *testing.T) { + cfg, port := httpMapping(t) + + // Occupy the port so srv.Start fails. + lc := &net.ListenConfig{} + + listener, err := lc.Listen(context.Background(), "tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + require.NoError(t, err) + + defer listener.Close() + + fs := afero.NewMemMapFs() + + data, err := yaml.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, afero.WriteFile(fs, "/config.yaml", data, 0o600)) + + err = cli.RunUncors(context.Background(), fs, []string{"-c", "/config.yaml", "--interactive=false"}) + require.Error(t, err) + }) + + t.Run("non-interactive: reloads valid config on file change", func(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + + cfg, port := httpMapping(t) + writeConfig(t, configPath, cfg) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := startProxy(ctx, afero.NewOsFs(), []string{"-c", configPath, "--interactive=false"}) + + waitForPort(t, net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + + // Overwrite with same valid config — watcher fires, reloadServer runs. + writeConfig(t, configPath, cfg) + + // Let the debounce + reload settle before stopping. + time.Sleep(200 * time.Millisecond) + + cancel() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("RunUncors did not exit after context cancellation") + } + }) + + t.Run("non-interactive: logs error when config reload produces invalid config", func(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + + cfg, port := httpMapping(t) + writeConfig(t, configPath, cfg) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := startProxy(ctx, afero.NewOsFs(), []string{"-c", configPath, "--interactive=false"}) + + waitForPort(t, net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + + // Write an invalid config (empty mappings) — reloadServer returns an error. + require.NoError(t, os.WriteFile(configPath, []byte("mappings: []\n"), 0o600)) + + // Let the debounce + reload settle before stopping. + time.Sleep(200 * time.Millisecond) + + cancel() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("RunUncors did not exit after context cancellation") + } + }) +} + +func TestGenerateCerts(t *testing.T) { + t.Run("returns error for unknown flag", func(t *testing.T) { + err := cli.GenerateCerts([]string{"--unknown-flag"}) + require.Error(t, err) + }) + + t.Run("generates CA certificate with valid args", func(t *testing.T) { + // Point HOME to a temp dir so certs go there, not ~/.config/uncors. + t.Setenv("HOME", t.TempDir()) + + err := cli.GenerateCerts([]string{"--validity-days=7"}) + require.NoError(t, err) + }) +} diff --git a/internal/di/public_api_test.go b/internal/di/public_api_test.go index 010070de..bed48628 100644 --- a/internal/di/public_api_test.go +++ b/internal/di/public_api_test.go @@ -2,6 +2,7 @@ package di_test import ( "bytes" + "net/http" "testing" "time" @@ -286,6 +287,92 @@ func TestContainerOverride(t *testing.T) { }) } +func TestContainerTargets(t *testing.T) { + defaultCache := config.CacheConfig{ + ExpirationTime: time.Minute, + MaxSize: 1024, + Methods: []string{http.MethodGet}, + } + + t.Run("returns single HTTP target", func(t *testing.T) { + container := di.NewContainer() + defer testutils.Close(t, container) + + cfg := &config.UncorsConfig{ + Mappings: config.Mappings{{ + From: hosts.Localhost.HTTPPort(18080), + To: hosts.Localhost.HTTP(), + }}, + CacheConfig: defaultCache, + } + + targets, err := container.Targets(cfg) + + require.NoError(t, err) + require.Len(t, targets, 1) + assert.Equal(t, "127.0.0.1:18080", targets[0].Address) + assert.False(t, targets[0].EnableTLS) + assert.NotNil(t, targets[0].Handler) + }) + + t.Run("returns HTTPS target with TLS enabled", func(t *testing.T) { + container := di.NewContainer() + defer testutils.Close(t, container) + + cfg := &config.UncorsConfig{ + Mappings: config.Mappings{{ + From: hosts.Localhost.HTTPSPort(18443), + To: hosts.Localhost.HTTP(), + }}, + CacheConfig: defaultCache, + } + + targets, err := container.Targets(cfg) + + require.NoError(t, err) + require.Len(t, targets, 1) + assert.Equal(t, "127.0.0.1:18443", targets[0].Address) + assert.True(t, targets[0].EnableTLS) + }) + + t.Run("groups two mappings on same port into one target", func(t *testing.T) { + container := di.NewContainer() + defer testutils.Close(t, container) + + cfg := &config.UncorsConfig{ + Mappings: config.Mappings{ + {From: hosts.Localhost1.HTTPPort(19000), To: hosts.Localhost.HTTP()}, + {From: hosts.Localhost2.HTTPPort(19000), To: hosts.Localhost.HTTP()}, + }, + CacheConfig: defaultCache, + } + + targets, err := container.Targets(cfg) + + require.NoError(t, err) + assert.Len(t, targets, 1) + assert.Equal(t, "127.0.0.1:19000", targets[0].Address) + }) + + t.Run("returns two targets for mappings on different ports", func(t *testing.T) { + container := di.NewContainer() + defer testutils.Close(t, container) + + cfg := &config.UncorsConfig{ + Mappings: config.Mappings{ + {From: hosts.Localhost1.HTTPPort(19001), To: hosts.Localhost.HTTP()}, + {From: hosts.Localhost2.HTTPPort(19002), To: hosts.Localhost.HTTP()}, + }, + CacheConfig: defaultCache, + } + + targets, err := container.Targets(cfg) + + require.NoError(t, err) + assert.Len(t, targets, 2) + }) +} + func TestContainerClose(t *testing.T) { t.Run("close with no closers succeeds", func(t *testing.T) { container := di.NewContainer() diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..c41881c0 --- /dev/null +++ b/main_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + + "github.com/evg4b/uncors/internal/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// saveLogger captures the current log writer and restores it after the test. +func saveLogger(t *testing.T) { + t.Helper() + + orig := log.Writer() + + t.Cleanup(func() { log.SetOutput(orig) }) +} + +// setArgs temporarily overrides os.Args and restores it via t.Cleanup. +func setArgs(t *testing.T, args []string) { + t.Helper() + + orig := os.Args + os.Args = args + + t.Cleanup(func() { os.Args = orig }) +} + +func TestSetupLogging(t *testing.T) { + t.Run("discards output when UNCORS_LOGGING is empty", func(t *testing.T) { + saveLogger(t) + t.Setenv("UNCORS_LOGGING", "") + + setupLogging() + + assert.Equal(t, io.Discard, log.Writer()) + }) + + t.Run("writes to file when UNCORS_LOGGING points to a valid path", func(t *testing.T) { + saveLogger(t) + logPath := filepath.Join(t.TempDir(), "test.log") + t.Setenv("UNCORS_LOGGING", logPath) + + setupLogging() + + require.NotEqual(t, io.Discard, log.Writer()) + + _, err := os.Stat(logPath) + assert.NoError(t, err) + }) + + t.Run("discards output when log file cannot be opened", func(t *testing.T) { + saveLogger(t) + t.Setenv("UNCORS_LOGGING", "/no-such-dir/test.log") + + setupLogging() + + assert.Equal(t, io.Discard, log.Writer()) + }) +} + +func TestMain_RunUncorsErrorPath(t *testing.T) { + saveLogger(t) + // Test args are not valid uncors config, so RunUncors returns an error. + // main() must swallow it gracefully (no panic). + assert.NotPanics(t, func() { + main() + }) +} + +func TestMain_GenerateCertsPath(t *testing.T) { + saveLogger(t) + // Point HOME to a temp dir so CA certificates go there, not ~/.config/uncors. + t.Setenv("HOME", t.TempDir()) + setArgs(t, []string{"uncors", cli.GenerateCertsCmd, "--validity-days=7"}) + + assert.NotPanics(t, func() { + main() + }) +} + +func TestMain_GenerateCertsErrorPath(t *testing.T) { + saveLogger(t) + setArgs(t, []string{"uncors", cli.GenerateCertsCmd, "--unknown-flag"}) + + assert.NotPanics(t, func() { + main() + }) +} From 420220458bc86833ea819c2bf6970232d2705cae Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 00:56:24 -0400 Subject: [PATCH 12/30] fix: prevent yaml.v3 round-trip failures for rewrite host and script fields RewritingOption.Host (urlt.Host) serialized as 'host: ""' without omitempty, causing ParseHost("") -> "empty host" on reload. Adding omitempty skips the field when zero. Script.MarshalYAML trims leading/trailing whitespace from the inline Script string and flattens the inline Matcher fields into a plain struct. This avoids a yaml.v3 bug where strings starting with \n produce |4 block scalars with incorrect content indentation, breaking the Marshal/Unmarshal round-trip used by the integration test harness. Co-Authored-By: Claude Sonnet 4.6 --- internal/config/rewrite.go | 2 +- internal/config/script.go | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/config/rewrite.go b/internal/config/rewrite.go index 2877bfc7..ba0c8804 100644 --- a/internal/config/rewrite.go +++ b/internal/config/rewrite.go @@ -10,7 +10,7 @@ import ( type RewritingOption struct { From string `yaml:"from"` To string `yaml:"to"` - Host urlt.Host `yaml:"host"` + Host urlt.Host `yaml:"host,omitempty"` } func (r RewritingOption) Clone() RewritingOption { diff --git a/internal/config/script.go b/internal/config/script.go index 43bca09b..2450ebeb 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "strings" "github.com/samber/lo" "github.com/spf13/afero" @@ -14,6 +15,30 @@ type Script struct { File string `yaml:"file"` } +// scriptMarshal is the canonical YAML representation of Script. +// Using a flat struct (no inline) and trimming multi-line script strings avoids +// a gopkg.in/yaml.v3 round-trip bug where strings starting with \n are +// serialized as "|4" block scalars with wrong content indentation. +type scriptMarshal struct { + Path string `yaml:"path,omitempty"` + Method string `yaml:"method,omitempty"` + Queries map[string]string `yaml:"queries,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Script string `yaml:"script,omitempty"` + File string `yaml:"file,omitempty"` +} + +func (s Script) MarshalYAML() (any, error) { + return scriptMarshal{ + Path: s.Matcher.Path, + Method: s.Matcher.Method, + Queries: s.Matcher.Queries, + Headers: s.Matcher.Headers, + Script: strings.TrimSpace(s.Script), + File: s.File, + }, nil +} + func (s *Script) Clone() Script { return Script{ Matcher: s.Matcher.Clone(), From 52f70b8ae8ee7056f47f736405033f68169c7226 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 01:13:42 -0400 Subject: [PATCH 13/30] test: add coverage for MarshalYAML, watcher edge cases, and cli reload diagnostic - TestScript_MarshalYAML: round-trip tests for inline script (with leading newline) and matcher field preservation, covering the MarshalYAML fix - TestNewConfigWatcher: three new subtests for errAlreadyWatching, empty path, and Close-without-Watch code paths (watcher.go branch coverage) - commands_test.go: add t.Logf diagnostic before require.NoError to capture the error value on failure, stabilising the reload subtest timing Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/commands_test.go | 4 +++ internal/config/script_test.go | 48 +++++++++++++++++++++++++++++++++ internal/config/watcher_test.go | 28 +++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 5b9e5641..c6fea8b4 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -164,6 +164,10 @@ func TestRunUncors(t *testing.T) { select { case err := <-errCh: + if err != nil { + t.Logf("RunUncors returned error: %v", err) + } + require.NoError(t, err) case <-time.After(10 * time.Second): t.Fatal("RunUncors did not exit after context cancellation") diff --git a/internal/config/script_test.go b/internal/config/script_test.go index 4bdb5a7a..f242f284 100644 --- a/internal/config/script_test.go +++ b/internal/config/script_test.go @@ -1,6 +1,7 @@ package config_test import ( + "strings" "testing" "github.com/evg4b/uncors/internal/config" @@ -8,8 +9,55 @@ import ( "github.com/go-http-utils/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) +func TestScript_MarshalYAML(t *testing.T) { + t.Run("inline script with leading newline round-trips without error", func(t *testing.T) { + script := config.Script{ + Matcher: config.RequestMatcher{Path: "/hello"}, + Script: ` +response:set_status(200) +response:set_body("ok") +`, + } + + data, err := yaml.Marshal(script) + require.NoError(t, err) + + var decoded config.Script + + err = yaml.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, "/hello", decoded.Matcher.Path) + // Leading/trailing whitespace is trimmed on marshal; the script content itself is preserved. + assert.Equal(t, strings.TrimSpace(script.Script), decoded.Script) + }) + + t.Run("matcher fields are preserved in round-trip", func(t *testing.T) { + script := config.Script{ + Matcher: config.RequestMatcher{ + Path: "/api/{id}", + Method: "POST", + }, + File: "/scripts/handler.lua", + } + + data, err := yaml.Marshal(script) + require.NoError(t, err) + + var decoded config.Script + + err = yaml.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, "/api/{id}", decoded.Matcher.Path) + assert.Equal(t, "POST", decoded.Matcher.Method) + assert.Equal(t, "/scripts/handler.lua", decoded.File) + }) +} + func TestRequestMatcher_Clone(t *testing.T) { original := config.RequestMatcher{ Path: "/api/test", diff --git a/internal/config/watcher_test.go b/internal/config/watcher_test.go index 12b8f190..330c58a9 100644 --- a/internal/config/watcher_test.go +++ b/internal/config/watcher_test.go @@ -168,6 +168,34 @@ func TestNewConfigWatcher(t *testing.T) { assert.True(t, waitForCall(called, watcherTimeout), "onChange not called after second atomic save") }) + t.Run("returns errAlreadyWatching when Watch called twice", func(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configFile, []byte(""), 0o600)) + + ctx := t.Context() + + watcher := config.NewWatcher(configFile) + err := watcher.Watch(ctx, func() {}) + require.NoError(t, err) + + defer testutils.Close(t, watcher) + + err = watcher.Watch(ctx, func() {}) + require.Error(t, err) + }) + + t.Run("Watch with empty path returns nil immediately", func(t *testing.T) { + watcher := config.NewWatcher("") + err := watcher.Watch(context.Background(), func() {}) + require.NoError(t, err) + }) + + t.Run("Close without Watch returns nil", func(t *testing.T) { + watcher := config.NewWatcher("/some/path.yaml") + require.NoError(t, watcher.Close()) + }) + t.Run("stops watching when context is cancelled", func(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "config.yaml") From 16ce76b984ad3f94c617824d857cf57014602cdb Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 18:57:51 -0400 Subject: [PATCH 14/30] refactor: remove debug parameter in favor of UNCORS_LOGGING env var Remove the --debug CLI flag and debug config field. Logging is now controlled exclusively via the UNCORS_LOGGING environment variable, simplifying the API. Updates tests and documentation to reflect this change. Co-Authored-By: Claude Haiku 4.5 --- CLAUDE.md | 7 +++---- internal/config/config.go | 5 ----- internal/config/config_test.go | 6 +----- internal/infra/loggings.go | 31 +++++++++++++++++++++++++++++++ main.go | 29 ++--------------------------- main_test.go | 7 ++++--- 6 files changed, 41 insertions(+), 44 deletions(-) create mode 100644 internal/infra/loggings.go diff --git a/CLAUDE.md b/CLAUDE.md index 2e286619..6cb0c9ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ UNCORS follows a clean layered architecture with middleware composition: **`internal/infra`** - Infrastructure services - HTTP client with connection pooling and proxy support -- Logger setup (logs to stderr or file with debug flag) +- Logger setup (logs to stderr or file based on UNCORS_LOGGING env var) - TLS certificate generation and handling **`internal/tui`** - Terminal UI and logging @@ -142,8 +142,7 @@ Key test flags: **Key Config Options** - `proxy`: Upstream proxy URL (optional) -- `interactive`: Enable TUI mode -- `debug`: Enable debug logging +- `interactive`: Enable TUI mode (default: true) - `port`: Listen port (default: 3000) - `mappings`: Array of request mappings (from/to hosts) @@ -177,7 +176,7 @@ Key test flags: 4. Update CONTRIBUTING.md if user-facing ### Debugging -- Enable debug logs: `./uncors -d` (writes to `uncors.log`) +- Enable logging: Set `UNCORS_LOGGING=/path/to/logfile` environment variable - Run single test: `go test -run TestName ./internal/handler/proxy/` - Race detector: Already enabled in `make test` and `make test-cover` - Integration tests: `make test-integration` (slower, real network) diff --git a/internal/config/config.go b/internal/config/config.go index 2ee03f61..df680976 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,7 +12,6 @@ import ( type UncorsConfig struct { Mappings Mappings `yaml:"mappings"` Proxy string `yaml:"proxy"` - Debug bool `yaml:"debug"` CacheConfig CacheConfig `yaml:"cache-config"` Interactive bool `yaml:"-"` } @@ -71,10 +70,6 @@ func applyFlagOverrides(cfg *UncorsConfig, flags *pflag.FlagSet) error { cfg.Proxy, _ = flags.GetString("proxy") } - if flags.Changed("debug") { - cfg.Debug, _ = flags.GetBool("debug") - } - if flags.Changed("interactive") { cfg.Interactive, _ = flags.GetBool("interactive") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9b31b418..a960c41a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -43,7 +43,6 @@ mappings: Accept-Encoding: deflate raw: demo proxy: http://localhost:8080 -debug: true https-port: 8081 cert-file: /etc/certificates/cert-file.pem key-file: /etc/certificates/key-file.key @@ -139,7 +138,6 @@ func TestLoadConfiguration(t *testing.T) { }, }, Proxy: hosts.Localhost.HTTPPort(8080).String(), - Debug: true, CacheConfig: config.CacheConfig{ ExpirationTime: time.Hour, MaxSize: 52428800, @@ -189,11 +187,10 @@ func TestLoadConfiguration(t *testing.T) { }, }, { - name: "CLI proxy and debug flags override config file values", + name: "CLI proxy flag overrides config file value", args: []string{ params.Config, fullConfigPath, "--proxy", "http://newproxy:9999", - "--debug=false", }, expected: &config.UncorsConfig{ Mappings: config.Mappings{ @@ -219,7 +216,6 @@ func TestLoadConfiguration(t *testing.T) { }, }, Proxy: "http://newproxy:9999", - Debug: false, CacheConfig: config.CacheConfig{ ExpirationTime: time.Hour, MaxSize: 52428800, Methods: []string{http.MethodGet, http.MethodPost}, diff --git a/internal/infra/loggings.go b/internal/infra/loggings.go new file mode 100644 index 00000000..c0531b4d --- /dev/null +++ b/internal/infra/loggings.go @@ -0,0 +1,31 @@ +package infra + +import ( + "io" + "log" + "os" + "path/filepath" +) + +const ( + logFileFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND + logFilePerm = 0o644 +) + +func SetupLogging() { + path := os.Getenv("UNCORS_LOGGING") + if path == "" { + log.SetOutput(io.Discard) + + return + } + + logFile, err := os.OpenFile(filepath.Clean(path), logFileFlags, logFilePerm) + if err != nil { + log.SetOutput(io.Discard) + + return + } + + log.SetOutput(logFile) +} diff --git a/main.go b/main.go index ade9800f..b8a1f9d3 100644 --- a/main.go +++ b/main.go @@ -2,43 +2,18 @@ package main import ( "context" - "io" - "log" "os" - "path/filepath" "github.com/evg4b/uncors/internal/cli" + "github.com/evg4b/uncors/internal/infra" "github.com/evg4b/uncors/internal/tui" "github.com/spf13/afero" ) var Version = "v0.7.0" -const ( - logFileFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND - logFilePerm = 0o644 -) - -func setupLogging() { - path := os.Getenv("UNCORS_LOGGING") - if path == "" { - log.SetOutput(io.Discard) - - return - } - - logFile, err := os.OpenFile(filepath.Clean(path), logFileFlags, logFilePerm) - if err != nil { - log.SetOutput(io.Discard) - - return - } - - log.SetOutput(logFile) -} - func main() { - setupLogging() + infra.SetupLogging() output := tui.NewCliOutput(os.Stdout) diff --git a/main_test.go b/main_test.go index c41881c0..72ba7abc 100644 --- a/main_test.go +++ b/main_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/evg4b/uncors/internal/cli" + "github.com/evg4b/uncors/internal/infra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,7 +37,7 @@ func TestSetupLogging(t *testing.T) { saveLogger(t) t.Setenv("UNCORS_LOGGING", "") - setupLogging() + infra.SetupLogging() assert.Equal(t, io.Discard, log.Writer()) }) @@ -46,7 +47,7 @@ func TestSetupLogging(t *testing.T) { logPath := filepath.Join(t.TempDir(), "test.log") t.Setenv("UNCORS_LOGGING", logPath) - setupLogging() + infra.SetupLogging() require.NotEqual(t, io.Discard, log.Writer()) @@ -58,7 +59,7 @@ func TestSetupLogging(t *testing.T) { saveLogger(t) t.Setenv("UNCORS_LOGGING", "/no-such-dir/test.log") - setupLogging() + infra.SetupLogging() assert.Equal(t, io.Discard, log.Writer()) }) From 089635bb60579c1560688a6111255d412c6e24cd Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 19:08:15 -0400 Subject: [PATCH 15/30] fix: protect Server.listeners with RWMutex and add SetupLogging tests Server.Shutdown could race with Server.Start (called via Restart) because both access s.listeners without synchronisation. Introduce a sync.RWMutex: Start holds the write lock while replacing the slice, Shutdown and Close snapshot the slice under the read lock before iterating. Also add internal/infra/loggings_test.go so that the SetupLogging function (moved here from main.go in the previous refactor) is covered at the package level rather than only through main_test.go cross-package calls. Co-Authored-By: Claude Sonnet 4.6 --- internal/infra/loggings_test.go | 54 +++++++++++++++++++++++++++++++++ internal/server/server.go | 15 +++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 internal/infra/loggings_test.go diff --git a/internal/infra/loggings_test.go b/internal/infra/loggings_test.go new file mode 100644 index 00000000..6a9c1035 --- /dev/null +++ b/internal/infra/loggings_test.go @@ -0,0 +1,54 @@ +package infra_test + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + + "github.com/evg4b/uncors/internal/infra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func saveLogWriter(t *testing.T) { + t.Helper() + + orig := log.Writer() + + t.Cleanup(func() { log.SetOutput(orig) }) +} + +func TestSetupLogging(t *testing.T) { + t.Run("discards output when UNCORS_LOGGING is empty", func(t *testing.T) { + saveLogWriter(t) + t.Setenv("UNCORS_LOGGING", "") + + infra.SetupLogging() + + assert.Equal(t, io.Discard, log.Writer()) + }) + + t.Run("writes to file when UNCORS_LOGGING points to a valid path", func(t *testing.T) { + saveLogWriter(t) + logPath := filepath.Join(t.TempDir(), "test.log") + t.Setenv("UNCORS_LOGGING", logPath) + + infra.SetupLogging() + + require.NotEqual(t, io.Discard, log.Writer()) + + _, err := os.Stat(logPath) + assert.NoError(t, err) + }) + + t.Run("discards output when log file cannot be opened", func(t *testing.T) { + saveLogWriter(t) + t.Setenv("UNCORS_LOGGING", "/no-such-dir/test.log") + + infra.SetupLogging() + + assert.Equal(t, io.Discard, log.Writer()) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 7786b9bc..47ee3dbc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,6 +29,7 @@ type Target struct { type Server struct { sync.WaitGroup + mu sync.RWMutex listeners []*PortListener manager *HostCertManager tracker IRequestTracker @@ -44,6 +45,7 @@ func New(manager *HostCertManager, tracker IRequestTracker) *Server { } func (s *Server) Start(ctx context.Context, targets []Target) error { + s.mu.Lock() s.listeners = lo.Map(targets, func(target Target, _ int) *PortListener { portCtx, portCtxCancel := context.WithCancel(ctx) @@ -65,6 +67,7 @@ func (s *Server) Start(ctx context.Context, targets []Target) error { return portListener }) + s.mu.Unlock() var launchWaitGroup sync.WaitGroup launchWaitGroup.Add(len(s.listeners)) @@ -111,13 +114,17 @@ func (s *Server) Shutdown(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() + s.mu.RLock() + listeners := s.listeners + s.mu.RUnlock() + var ( waitGroup sync.WaitGroup errsMu sync.Mutex errs []error ) - for _, server := range s.listeners { + for _, server := range listeners { waitGroup.Add(1) go func(srv *PortListener) { defer waitGroup.Done() @@ -154,9 +161,13 @@ func (s *Server) Wait() { } func (s *Server) Close() error { + s.mu.RLock() + listeners := s.listeners + s.mu.RUnlock() + var errs []error - for _, portListener := range s.listeners { + for _, portListener := range listeners { err := portListener.Close() if err != nil { errs = append(errs, err) From 54e07406083be73755d307dfa975e1cb22bfa84c Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 19:16:35 -0400 Subject: [PATCH 16/30] test: split cli test file to match code organization Move TestGenerateCerts to generate_certs_test.go and TestRunUncors to run_uncors_test.go. Tests are now co-located with the functions they test, matching the split of commands.go into separate modules. Also move helper functions (httpMapping, waitForPort, writeConfig, startProxy) to run_uncors_test.go where they are used. Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/generate_certs_test.go | 23 +++++++++++++++++++ .../{commands_test.go => run_uncors_test.go} | 15 ------------ 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 internal/cli/generate_certs_test.go rename internal/cli/{commands_test.go => run_uncors_test.go} (92%) diff --git a/internal/cli/generate_certs_test.go b/internal/cli/generate_certs_test.go new file mode 100644 index 00000000..5e4b932d --- /dev/null +++ b/internal/cli/generate_certs_test.go @@ -0,0 +1,23 @@ +package cli_test + +import ( + "testing" + + "github.com/evg4b/uncors/internal/cli" + "github.com/stretchr/testify/require" +) + +func TestGenerateCerts(t *testing.T) { + t.Run("returns error for unknown flag", func(t *testing.T) { + err := cli.GenerateCerts([]string{"--unknown-flag"}) + require.Error(t, err) + }) + + t.Run("generates CA certificate with valid args", func(t *testing.T) { + // Point HOME to a temp dir so certs go there, not ~/.config/uncors. + t.Setenv("HOME", t.TempDir()) + + err := cli.GenerateCerts([]string{"--validity-days=7"}) + require.NoError(t, err) + }) +} diff --git a/internal/cli/commands_test.go b/internal/cli/run_uncors_test.go similarity index 92% rename from internal/cli/commands_test.go rename to internal/cli/run_uncors_test.go index c6fea8b4..966d1657 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/run_uncors_test.go @@ -203,18 +203,3 @@ func TestRunUncors(t *testing.T) { } }) } - -func TestGenerateCerts(t *testing.T) { - t.Run("returns error for unknown flag", func(t *testing.T) { - err := cli.GenerateCerts([]string{"--unknown-flag"}) - require.Error(t, err) - }) - - t.Run("generates CA certificate with valid args", func(t *testing.T) { - // Point HOME to a temp dir so certs go there, not ~/.config/uncors. - t.Setenv("HOME", t.TempDir()) - - err := cli.GenerateCerts([]string{"--validity-days=7"}) - require.NoError(t, err) - }) -} From 912b26156dea105d4299b819089e015564326a17 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Tue, 23 Jun 2026 19:23:03 -0400 Subject: [PATCH 17/30] refactor: split commands.go into separate modules by responsibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate_certs.go: GenerateCerts function for CLI cert generation - run_uncors.go: RunUncors entry point that routes to interactive/non-interactive - run_ineractive.go: runIneractive for TUI mode (BubbleTea-based) - run_non_ineractive.go: runNonIneractive for headless mode with config watching Also: - Remove --debug CLI flag; logging now controlled solely via UNCORS_LOGGING env var - Rename parameters for consistency: uncorsConfig→cfg, configPath→cfgPath - Add context.Context to runIneractive for proper lifecycle management Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/commands.go | 75 ------------------------------ internal/cli/generate_certs.go | 34 ++++++++++++++ internal/cli/run_ineractive.go | 37 +++++++++++++++ internal/cli/run_non_ineractive.go | 16 ++----- internal/cli/run_uncors.go | 21 +++++++++ internal/config/flags.go | 1 - 6 files changed, 97 insertions(+), 87 deletions(-) delete mode 100644 internal/cli/commands.go create mode 100644 internal/cli/generate_certs.go create mode 100644 internal/cli/run_ineractive.go create mode 100644 internal/cli/run_uncors.go diff --git a/internal/cli/commands.go b/internal/cli/commands.go deleted file mode 100644 index cb55f0ab..00000000 --- a/internal/cli/commands.go +++ /dev/null @@ -1,75 +0,0 @@ -package cli - -import ( - "context" - "os" - - tea "charm.land/bubbletea/v2" - "github.com/evg4b/uncors/internal/config" - "github.com/evg4b/uncors/internal/di" - uncor "github.com/evg4b/uncors/internal/uncors_app" - "github.com/spf13/afero" - "github.com/spf13/pflag" -) - -const GenerateCertsCmd = "generate-certs" - -func GenerateCerts(args []string) error { - fs := afero.NewOsFs() - - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - // di.WithVersion("Version"), - ) - defer container.Close() - - cmd := container.GenerateCertsCommand() - - flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) - cmd.DefineFlags(flags) - - err := flags.Parse(args) - if err != nil { - return err - } - - return cmd.Execute() -} - -func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { - uncorsConfig, path, err := config.LoadConfiguration(fs, args) - if err != nil { - return err - } - - if uncorsConfig.Interactive { - return runIneractive(fs, uncorsConfig, path, args) - } - - return runNonIneractive(ctx, fs, uncorsConfig, path, args) -} - -func runIneractive(fs afero.Fs, uncorsConfig *config.UncorsConfig, configPath string, args []string) error { - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - // di.WithVersion("Version"), - ) - defer container.Close() - - app := uncor.NewUncorsApp( - container, - configPath, - uncorsConfig, - func() *config.UncorsConfig { - reloaded, _, _ := config.LoadConfiguration(container.Fs(), args) - - return reloaded - }, - ) - - _, err := tea.NewProgram(app).Run() - - return err -} diff --git a/internal/cli/generate_certs.go b/internal/cli/generate_certs.go new file mode 100644 index 00000000..2819a74e --- /dev/null +++ b/internal/cli/generate_certs.go @@ -0,0 +1,34 @@ +package cli + +import ( + "os" + + "github.com/evg4b/uncors/internal/di" + "github.com/spf13/afero" + "github.com/spf13/pflag" +) + +const GenerateCertsCmd = "generate-certs" + +func GenerateCerts(args []string) error { + fs := afero.NewOsFs() + + container := di.NewContainer( + di.WithFs(fs), + di.WithStdout(os.Stdout), + // di.WithVersion("Version"), + ) + defer container.Close() + + cmd := container.GenerateCertsCommand() + + flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) + cmd.DefineFlags(flags) + + err := flags.Parse(args) + if err != nil { + return err + } + + return cmd.Execute() +} diff --git a/internal/cli/run_ineractive.go b/internal/cli/run_ineractive.go new file mode 100644 index 00000000..688dd2df --- /dev/null +++ b/internal/cli/run_ineractive.go @@ -0,0 +1,37 @@ +package cli + +import ( + "context" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/di" + uncor "github.com/evg4b/uncors/internal/uncors_app" + "github.com/spf13/afero" +) + +func runIneractive(ctx context.Context, fs afero.Fs, cfg *config.UncorsConfig, cfgPath string, args []string) error { + container := di.NewContainer( + di.WithFs(fs), + di.WithStdout(os.Stdout), + // di.WithVersion("Version"), + ) + defer container.Close() + + app := uncor.NewUncorsApp( + container, + cfgPath, + cfg, + func() *config.UncorsConfig { + reloaded, _, _ := config.LoadConfiguration(container.Fs(), args) + + return reloaded + }, + ) + + _, err := tea.NewProgram(app, tea.WithContext(ctx)). + Run() + + return err +} diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go index cff328ee..29e487d8 100644 --- a/internal/cli/run_non_ineractive.go +++ b/internal/cli/run_non_ineractive.go @@ -17,13 +17,7 @@ import ( const shutdownTimeout = 15 * time.Second -func runNonIneractive( - ctx context.Context, - fs afero.Fs, - uncorsConfig *config.UncorsConfig, - configPath string, - args []string, -) error { +func runNonIneractive(ctx context.Context, fs afero.Fs, cfg *config.UncorsConfig, cfgPath string, args []string) error { container := di.NewContainer( di.WithFs(fs), di.WithStdout(os.Stdout), @@ -36,10 +30,10 @@ func runNonIneractive( output.Print("") output.WarnBox(tui.DisclaimerMessage) output.Print("") - output.InfoBox(uncorsConfig.Mappings.String()) + output.InfoBox(cfg.Mappings.String()) output.Print("") - targets, err := container.Targets(uncorsConfig) + targets, err := container.Targets(cfg) if err != nil { return err } @@ -51,10 +45,10 @@ func runNonIneractive( return err } - go startVersionChecker(ctx, container, uncorsConfig.Proxy) + go startVersionChecker(ctx, container, cfg.Proxy) go func() { - watcher := config.NewWatcher(configPath) + watcher := config.NewWatcher(cfgPath) err := watcher.Watch(ctx, func() { reloadServer(ctx, container, srv, args) }) if err != nil { diff --git a/internal/cli/run_uncors.go b/internal/cli/run_uncors.go new file mode 100644 index 00000000..b37d735f --- /dev/null +++ b/internal/cli/run_uncors.go @@ -0,0 +1,21 @@ +package cli + +import ( + "context" + + "github.com/evg4b/uncors/internal/config" + "github.com/spf13/afero" +) + +func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { + uncorsConfig, path, err := config.LoadConfiguration(fs, args) + if err != nil { + return err + } + + if uncorsConfig.Interactive { + return runIneractive(ctx, fs, uncorsConfig, path, args) + } + + return runNonIneractive(ctx, fs, uncorsConfig, path, args) +} diff --git a/internal/config/flags.go b/internal/config/flags.go index 6da8727a..c985f0da 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -8,7 +8,6 @@ func defineFlags() *pflag.FlagSet { flags.StringSliceP("to", "t", []string{}, "Target host with protocol for the resource to be proxied") flags.StringSliceP("from", "f", []string{}, "Local host with protocol for the resource from which proxying will take place") //nolint: lll flags.String("proxy", "", "HTTP/HTTPS proxy for requests to the real server (uses system proxy by default)") - flags.Bool("debug", false, "Show debug output") flags.StringP("config", "c", "", "Path to the configuration file") flags.Bool("interactive", true, "") From 3da2d1cede88e8a05e7c510da6c43b2e9f49bca1 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 11:14:17 -0400 Subject: [PATCH 18/30] Provide version --- .goreleaser.yaml | 2 +- Makefile | 2 +- internal/cli/generate_certs.go | 2 +- internal/cli/run_ineractive.go | 2 +- internal/cli/run_non_ineractive.go | 2 +- internal/cli/version.go | 3 +++ main.go | 10 ++++------ 7 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 internal/cli/version.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b827d9f0..913e633f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -21,7 +21,7 @@ builds: goarch: '386' main: '.' ldflags: - - -s -w -X main.Version={{ .Version }} + - -s -w -X github.com/evg4b/uncors/internal/cli.Version={{ .Version }} tags: [ release ] checksum: name_template: 'checksums.txt' diff --git a/Makefile b/Makefile index 9ce26351..3803e6a0 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ GOTEST := $(GO) test GOBUILD := $(GO) build GOINSTALL := $(GO) install VERSION := $(shell git rev-parse --short HEAD) -LDFLAGS := -ldflags="-s -w -X 'main.Version=$(VERSION)'" +LDFLAGS := -ldflags="-s -w -X 'github.com/evg4b/uncors/internal/cli.Version=$(VERSION)'" COVERAGE_FILE := coverage.out BINARY_NAME := uncors BINARY_WINDOWS := $(BINARY_NAME).exe diff --git a/internal/cli/generate_certs.go b/internal/cli/generate_certs.go index 2819a74e..be7effa8 100644 --- a/internal/cli/generate_certs.go +++ b/internal/cli/generate_certs.go @@ -16,7 +16,7 @@ func GenerateCerts(args []string) error { container := di.NewContainer( di.WithFs(fs), di.WithStdout(os.Stdout), - // di.WithVersion("Version"), + di.WithVersion(Version), ) defer container.Close() diff --git a/internal/cli/run_ineractive.go b/internal/cli/run_ineractive.go index 688dd2df..d46283d5 100644 --- a/internal/cli/run_ineractive.go +++ b/internal/cli/run_ineractive.go @@ -15,7 +15,7 @@ func runIneractive(ctx context.Context, fs afero.Fs, cfg *config.UncorsConfig, c container := di.NewContainer( di.WithFs(fs), di.WithStdout(os.Stdout), - // di.WithVersion("Version"), + di.WithVersion(Version), ) defer container.Close() diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go index 29e487d8..b8a33598 100644 --- a/internal/cli/run_non_ineractive.go +++ b/internal/cli/run_non_ineractive.go @@ -21,7 +21,7 @@ func runNonIneractive(ctx context.Context, fs afero.Fs, cfg *config.UncorsConfig container := di.NewContainer( di.WithFs(fs), di.WithStdout(os.Stdout), - // di.WithVersion("Version"), + di.WithVersion(Version), ) defer container.Close() diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 00000000..166fef8d --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,3 @@ +package cli + +var Version = "v0.0.0" diff --git a/main.go b/main.go index b8a1f9d3..06a657f5 100644 --- a/main.go +++ b/main.go @@ -10,17 +10,14 @@ import ( "github.com/spf13/afero" ) -var Version = "v0.7.0" - func main() { infra.SetupLogging() - output := tui.NewCliOutput(os.Stdout) - if len(os.Args) >= 2 && os.Args[1] == cli.GenerateCertsCmd { err := cli.GenerateCerts(os.Args[2:]) if err != nil { - output.Error(err) + tui.NewCliOutput(os.Stdout). + Error(err) } return @@ -28,6 +25,7 @@ func main() { err := cli.RunUncors(context.Background(), afero.NewOsFs(), os.Args[1:]) if err != nil { - output.Error(err) + tui.NewCliOutput(os.Stdout). + Error(err) } } From fdf876bbd18cb6482d9634fb812d0fc8da7b41ec Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 11:57:46 -0400 Subject: [PATCH 19/30] Restore help functionality --- internal/cli/generate_certs.go | 6 +++++- internal/cli/run_ineractive.go | 17 +++++++---------- internal/cli/run_non_ineractive.go | 16 +++++++--------- internal/cli/run_uncors.go | 13 +++++++++++-- internal/config/flags.go | 3 +-- main.go | 8 ++++++-- 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/internal/cli/generate_certs.go b/internal/cli/generate_certs.go index be7effa8..bb507e80 100644 --- a/internal/cli/generate_certs.go +++ b/internal/cli/generate_certs.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "os" "github.com/evg4b/uncors/internal/di" @@ -27,7 +28,10 @@ func GenerateCerts(args []string) error { err := flags.Parse(args) if err != nil { - return err + if !errors.Is(err, pflag.ErrHelp) { + return err + } + return nil } return cmd.Execute() diff --git a/internal/cli/run_ineractive.go b/internal/cli/run_ineractive.go index d46283d5..78f75134 100644 --- a/internal/cli/run_ineractive.go +++ b/internal/cli/run_ineractive.go @@ -2,23 +2,20 @@ package cli import ( "context" - "os" tea "charm.land/bubbletea/v2" "github.com/evg4b/uncors/internal/config" "github.com/evg4b/uncors/internal/di" uncor "github.com/evg4b/uncors/internal/uncors_app" - "github.com/spf13/afero" ) -func runIneractive(ctx context.Context, fs afero.Fs, cfg *config.UncorsConfig, cfgPath string, args []string) error { - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - di.WithVersion(Version), - ) - defer container.Close() - +func runIneractive( + ctx context.Context, + container *di.Container, + cfg *config.UncorsConfig, + cfgPath string, + args []string, +) error { app := uncor.NewUncorsApp( container, cfgPath, diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go index b8a33598..f6337621 100644 --- a/internal/cli/run_non_ineractive.go +++ b/internal/cli/run_non_ineractive.go @@ -12,19 +12,17 @@ import ( "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/internal/server" "github.com/evg4b/uncors/internal/tui" - "github.com/spf13/afero" ) const shutdownTimeout = 15 * time.Second -func runNonIneractive(ctx context.Context, fs afero.Fs, cfg *config.UncorsConfig, cfgPath string, args []string) error { - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - di.WithVersion(Version), - ) - defer container.Close() - +func runNonIneractive( + ctx context.Context, + container *di.Container, + cfg *config.UncorsConfig, + cfgPath string, + args []string, +) error { output := container.CliOutput() tui.PrintLogo(output, container.Version()) output.Print("") diff --git a/internal/cli/run_uncors.go b/internal/cli/run_uncors.go index b37d735f..1ce4480c 100644 --- a/internal/cli/run_uncors.go +++ b/internal/cli/run_uncors.go @@ -2,8 +2,10 @@ package cli import ( "context" + "os" "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/di" "github.com/spf13/afero" ) @@ -13,9 +15,16 @@ func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { return err } + container := di.NewContainer( + di.WithFs(fs), + di.WithStdout(os.Stdout), + di.WithVersion(Version), + ) + defer container.Close() + if uncorsConfig.Interactive { - return runIneractive(ctx, fs, uncorsConfig, path, args) + return runIneractive(ctx, container, uncorsConfig, path, args) } - return runNonIneractive(ctx, fs, uncorsConfig, path, args) + return runNonIneractive(ctx, container, uncorsConfig, path, args) } diff --git a/internal/config/flags.go b/internal/config/flags.go index c985f0da..3424210d 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -4,12 +4,11 @@ import "github.com/spf13/pflag" func defineFlags() *pflag.FlagSet { flags := pflag.NewFlagSet("uncors", pflag.ContinueOnError) - flags.Usage = pflag.Usage flags.StringSliceP("to", "t", []string{}, "Target host with protocol for the resource to be proxied") flags.StringSliceP("from", "f", []string{}, "Local host with protocol for the resource from which proxying will take place") //nolint: lll flags.String("proxy", "", "HTTP/HTTPS proxy for requests to the real server (uses system proxy by default)") flags.StringP("config", "c", "", "Path to the configuration file") - flags.Bool("interactive", true, "") + flags.Bool("interactive", true, "Run application in interactive TUI mode") return flags } diff --git a/main.go b/main.go index 06a657f5..e6fff4c4 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,14 @@ package main import ( "context" + "errors" "os" "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/infra" "github.com/evg4b/uncors/internal/tui" "github.com/spf13/afero" + "github.com/spf13/pflag" ) func main() { @@ -25,7 +27,9 @@ func main() { err := cli.RunUncors(context.Background(), afero.NewOsFs(), os.Args[1:]) if err != nil { - tui.NewCliOutput(os.Stdout). - Error(err) + if !errors.Is(err, pflag.ErrHelp) { + tui.NewCliOutput(os.Stdout). + Error(err) + } } } From 04c04ec99f80f1364355bd16e52b3347a79eb122 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 12:01:18 -0400 Subject: [PATCH 20/30] fix: add missing blank line before return in GenerateCerts (nlreturn) Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/generate_certs.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cli/generate_certs.go b/internal/cli/generate_certs.go index bb507e80..6af25cb1 100644 --- a/internal/cli/generate_certs.go +++ b/internal/cli/generate_certs.go @@ -31,6 +31,7 @@ func GenerateCerts(args []string) error { if !errors.Is(err, pflag.ErrHelp) { return err } + return nil } From 6febd9c49283a5bac688a35842f176d3af36ad75 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 13:13:37 -0400 Subject: [PATCH 21/30] Restored help command --- internal/cli/generate_certs.go | 2 +- internal/cli/run_ineractive.go | 2 +- internal/cli/run_non_ineractive.go | 2 +- internal/cli/run_uncors.go | 26 +++++++++++++++++++----- internal/commands/generate_certs.go | 8 +++++++- internal/commands/generate_certs_test.go | 21 ++++++++++--------- internal/config/config.go | 4 ++-- internal/config/config_test.go | 10 +++++---- internal/config/flags.go | 14 +++++++++++-- main.go | 8 ++------ 10 files changed, 64 insertions(+), 33 deletions(-) diff --git a/internal/cli/generate_certs.go b/internal/cli/generate_certs.go index 6af25cb1..b954b2fd 100644 --- a/internal/cli/generate_certs.go +++ b/internal/cli/generate_certs.go @@ -24,7 +24,7 @@ func GenerateCerts(args []string) error { cmd := container.GenerateCertsCommand() flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, Version) err := flags.Parse(args) if err != nil { diff --git a/internal/cli/run_ineractive.go b/internal/cli/run_ineractive.go index 78f75134..69030f87 100644 --- a/internal/cli/run_ineractive.go +++ b/internal/cli/run_ineractive.go @@ -21,7 +21,7 @@ func runIneractive( cfgPath, cfg, func() *config.UncorsConfig { - reloaded, _, _ := config.LoadConfiguration(container.Fs(), args) + reloaded, _, _ := config.LoadConfiguration(container.Fs(), Version, args) return reloaded }, diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go index f6337621..e931bada 100644 --- a/internal/cli/run_non_ineractive.go +++ b/internal/cli/run_non_ineractive.go @@ -85,7 +85,7 @@ func runNonIneractive( func reloadServer(ctx context.Context, container *di.Container, srv *server.Server, args []string) { output := container.CliOutput() - newUncorsConfig, _, err := config.LoadConfiguration(container.Fs(), args) + newUncorsConfig, _, err := config.LoadConfiguration(container.Fs(), Version, args) if err != nil { output.Error(err) diff --git a/internal/cli/run_uncors.go b/internal/cli/run_uncors.go index 1ce4480c..8fc60393 100644 --- a/internal/cli/run_uncors.go +++ b/internal/cli/run_uncors.go @@ -2,29 +2,45 @@ package cli import ( "context" + "errors" "os" "github.com/evg4b/uncors/internal/config" "github.com/evg4b/uncors/internal/di" "github.com/spf13/afero" + "github.com/spf13/pflag" ) func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { - uncorsConfig, path, err := config.LoadConfiguration(fs, args) + uncorsConfig, path, err := config.LoadConfiguration(fs, Version, args) if err != nil { - return err + if !errors.Is(err, pflag.ErrHelp) { + return err + } + return nil } + var containerError error + container := di.NewContainer( di.WithFs(fs), di.WithStdout(os.Stdout), di.WithVersion(Version), ) - defer container.Close() + defer func() { + containerError = container.Close() + }() + var runError error if uncorsConfig.Interactive { - return runIneractive(ctx, container, uncorsConfig, path, args) + runError = runIneractive(ctx, container, uncorsConfig, path, args) + } else { + runError = runNonIneractive(ctx, container, uncorsConfig, path, args) + } + + if runError != nil && !errors.Is(runError, pflag.ErrHelp) { + return runError } - return runNonIneractive(ctx, container, uncorsConfig, path, args) + return containerError } diff --git a/internal/commands/generate_certs.go b/internal/commands/generate_certs.go index 6f1ddc6b..01f9cb8b 100644 --- a/internal/commands/generate_certs.go +++ b/internal/commands/generate_certs.go @@ -8,6 +8,7 @@ import ( "github.com/evg4b/uncors/internal/contracts" "github.com/evg4b/uncors/internal/helpers" "github.com/evg4b/uncors/internal/server" + "github.com/evg4b/uncors/internal/tui" "github.com/spf13/afero" "github.com/spf13/pflag" ) @@ -32,7 +33,12 @@ func NewGenerateCertsCommand(options ...Option) *GenerateCertsCommand { } // DefineFlags defines command-line flags for the generate-certs command. -func (c *GenerateCertsCommand) DefineFlags(flags *pflag.FlagSet) { +func (c *GenerateCertsCommand) DefineFlags(flags *pflag.FlagSet, version string) { + flags.Usage = func() { + tui.PrintLogo(flags.Output(), version) + fmt.Fprintln(flags.Output(), "") + fmt.Fprintln(flags.Output(), flags.FlagUsages()) + } flags.IntVar(&c.validityDays, "validity-days", defaultValidityDays, "Certificate validity period in days") flags.BoolVar(&c.force, "force", false, "Force overwrite existing CA certificates") } diff --git a/internal/commands/generate_certs_test.go b/internal/commands/generate_certs_test.go index b7d32773..97e91f25 100644 --- a/internal/commands/generate_certs_test.go +++ b/internal/commands/generate_certs_test.go @@ -18,6 +18,7 @@ const ( configDir = ".config" caCertFile = "ca.crt" caKeyFile = "ca.key" + version = "v0.0.0" ) func TestNewGenerateCertsCommand(t *testing.T) { @@ -40,7 +41,7 @@ func TestGenerateCertsCommand_DefineFlags(t *testing.T) { ) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, version) flag := flags.Lookup("validity-days") assert.NotNil(t, flag) @@ -55,7 +56,7 @@ func TestGenerateCertsCommand_DefineFlags(t *testing.T) { ) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, version) flag := flags.Lookup("force") assert.NotNil(t, flag) @@ -79,7 +80,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, version) err := cmd.Execute() require.NoError(t, err) @@ -112,7 +113,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, version) err := flags.Set("validity-days", "730") require.NoError(t, err) @@ -146,7 +147,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags1 := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd1.DefineFlags(flags1) + cmd1.DefineFlags(flags1, version) err := cmd1.Execute() require.NoError(t, err) @@ -155,7 +156,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags2 := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd2.DefineFlags(flags2) + cmd2.DefineFlags(flags2, version) err = cmd2.Execute() require.Error(t, err) }) @@ -173,7 +174,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags1 := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd1.DefineFlags(flags1) + cmd1.DefineFlags(flags1, version) err := cmd1.Execute() require.NoError(t, err) @@ -189,7 +190,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags2 := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd2.DefineFlags(flags2) + cmd2.DefineFlags(flags2, version) err = flags2.Set("force", "true") require.NoError(t, err) @@ -216,7 +217,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, version) err := cmd.Execute() require.NoError(t, err) @@ -251,7 +252,7 @@ func TestGenerateCertsCommand_Execute(t *testing.T) { commands.WithOutput(mocks.NoopOutput()), ) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) - cmd.DefineFlags(flags) + cmd.DefineFlags(flags, version) err := cmd.Execute() require.Error(t, err) diff --git a/internal/config/config.go b/internal/config/config.go index df680976..38a913ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,8 +16,8 @@ type UncorsConfig struct { Interactive bool `yaml:"-"` } -func LoadConfiguration(fs afero.Fs, args []string) (*UncorsConfig, string, error) { - flags := defineFlags() +func LoadConfiguration(fs afero.Fs, version string, args []string) (*UncorsConfig, string, error) { + flags := defineFlags(version) err := flags.Parse(args) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a960c41a..614b3cfa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -81,6 +81,8 @@ func makeTestFs(t *testing.T) afero.Fs { }) } +const version = "v0.0.0" + func TestLoadConfiguration(t *testing.T) { fs := makeTestFs(t) @@ -245,7 +247,7 @@ func TestLoadConfiguration(t *testing.T) { for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - actual, _, err := config.LoadConfiguration(fs, testCase.args) + actual, _, err := config.LoadConfiguration(fs, "", testCase.args) require.NoError(t, err) assert.Equal(t, testCase.expected, actual) @@ -256,13 +258,13 @@ func TestLoadConfiguration(t *testing.T) { t.Run("returns config file path", func(t *testing.T) { t.Run("empty when no config file flag", func(t *testing.T) { args := []string{params.From, hosts.Localhost1.HTTP().String(), params.To, hosts.Github.Host().String()} - _, configPath, err := config.LoadConfiguration(afero.NewMemMapFs(), args) + _, configPath, err := config.LoadConfiguration(afero.NewMemMapFs(), version, args) require.NoError(t, err) assert.Empty(t, configPath) }) t.Run("returns the given config path", func(t *testing.T) { - _, configPath, err := config.LoadConfiguration(fs, []string{params.Config, minimalConfigPath}) + _, configPath, err := config.LoadConfiguration(fs, version, []string{params.Config, minimalConfigPath}) require.NoError(t, err) assert.Equal(t, minimalConfigPath, configPath) }) @@ -327,7 +329,7 @@ func TestLoadConfiguration(t *testing.T) { for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - _, _, err := config.LoadConfiguration(fs, testCase.args) + _, _, err := config.LoadConfiguration(fs, version, testCase.args) assert.EqualError(t, err, testCase.expectedErr) }) } diff --git a/internal/config/flags.go b/internal/config/flags.go index 3424210d..1460eb09 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -1,9 +1,19 @@ package config -import "github.com/spf13/pflag" +import ( + "fmt" -func defineFlags() *pflag.FlagSet { + "github.com/evg4b/uncors/internal/tui" + "github.com/spf13/pflag" +) + +func defineFlags(version string) *pflag.FlagSet { flags := pflag.NewFlagSet("uncors", pflag.ContinueOnError) + flags.Usage = func() { + tui.PrintLogo(flags.Output(), version) + fmt.Fprintln(flags.Output(), "") + fmt.Fprintln(flags.Output(), flags.FlagUsages()) + } flags.StringSliceP("to", "t", []string{}, "Target host with protocol for the resource to be proxied") flags.StringSliceP("from", "f", []string{}, "Local host with protocol for the resource from which proxying will take place") //nolint: lll flags.String("proxy", "", "HTTP/HTTPS proxy for requests to the real server (uses system proxy by default)") diff --git a/main.go b/main.go index e6fff4c4..06a657f5 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,12 @@ package main import ( "context" - "errors" "os" "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/infra" "github.com/evg4b/uncors/internal/tui" "github.com/spf13/afero" - "github.com/spf13/pflag" ) func main() { @@ -27,9 +25,7 @@ func main() { err := cli.RunUncors(context.Background(), afero.NewOsFs(), os.Args[1:]) if err != nil { - if !errors.Is(err, pflag.ErrHelp) { - tui.NewCliOutput(os.Stdout). - Error(err) - } + tui.NewCliOutput(os.Stdout). + Error(err) } } From 43afa20bfa4c133c1187d630b6f534eef04f0e56 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 13:23:48 -0400 Subject: [PATCH 22/30] Added version --- internal/cli/run_uncors.go | 22 ++++++++++++++-------- internal/config/config.go | 19 +++++++++++++++++-- internal/config/flags.go | 10 ++++++++-- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/internal/cli/run_uncors.go b/internal/cli/run_uncors.go index 8fc60393..7c0e0ca2 100644 --- a/internal/cli/run_uncors.go +++ b/internal/cli/run_uncors.go @@ -12,14 +12,6 @@ import ( ) func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { - uncorsConfig, path, err := config.LoadConfiguration(fs, Version, args) - if err != nil { - if !errors.Is(err, pflag.ErrHelp) { - return err - } - return nil - } - var containerError error container := di.NewContainer( @@ -31,6 +23,20 @@ func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { containerError = container.Close() }() + uncorsConfig, path, err := config.LoadConfiguration(fs, Version, args) + if err != nil { + if errors.Is(err, config.ErrVersionRequested) { + println(container.Version()) + return nil + } + + if !errors.Is(err, pflag.ErrHelp) && !errors.Is(err, config.ErrVersionRequested) { + return err + } + + return nil + } + var runError error if uncorsConfig.Interactive { runError = runIneractive(ctx, container, uncorsConfig, path, args) diff --git a/internal/config/config.go b/internal/config/config.go index 38a913ac..4abcc172 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,10 @@ import ( "gopkg.in/yaml.v3" ) +// ErrVersionRequested is returned when the --version flag is set so that the +// caller can exit cleanly after the version has been printed. +var ErrVersionRequested = errors.New("version requested") + type UncorsConfig struct { Mappings Mappings `yaml:"mappings"` Proxy string `yaml:"proxy"` @@ -17,13 +21,24 @@ type UncorsConfig struct { } func LoadConfiguration(fs afero.Fs, version string, args []string) (*UncorsConfig, string, error) { - flags := defineFlags(version) + flags, err := defineFlags(version) + if err != nil { + return nil, "", err + } - err := flags.Parse(args) + err = flags.Parse(args) if err != nil { return nil, "", fmt.Errorf("failed parsing flags: %w", err) } + printVersion, err := flags.GetBool("version") + if err != nil { + return nil, "", err + } + if printVersion { + return nil, "", ErrVersionRequested + } + cfg := defaultConfig() configPath, _ := flags.GetString("config") diff --git a/internal/config/flags.go b/internal/config/flags.go index 1460eb09..5f4373a8 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/pflag" ) -func defineFlags(version string) *pflag.FlagSet { +func defineFlags(version string) (*pflag.FlagSet, error) { flags := pflag.NewFlagSet("uncors", pflag.ContinueOnError) flags.Usage = func() { tui.PrintLogo(flags.Output(), version) @@ -19,6 +19,12 @@ func defineFlags(version string) *pflag.FlagSet { flags.String("proxy", "", "HTTP/HTTPS proxy for requests to the real server (uses system proxy by default)") flags.StringP("config", "c", "", "Path to the configuration file") flags.Bool("interactive", true, "Run application in interactive TUI mode") + flags.BoolP("version", "v", false, "Print the version and exit") - return flags + err := flags.MarkHidden("version") + if err != nil { + return nil, err + } + + return flags, nil } From e53778c988c3e844a4cc04155bf5aa42f32c08da Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 13:53:53 -0400 Subject: [PATCH 23/30] fix: replace println with fmt.Fprintln and add missing blank lines (forbidigo/nlreturn/wsl_v5) - run_uncors.go: replace forbidden println with fmt.Fprintln(os.Stdout, ...) and add blank line before return nil after version print - config.go: add blank line before if printVersion block (wsl_v5) Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/run_uncors.go | 10 ++++++---- internal/config/config.go | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/cli/run_uncors.go b/internal/cli/run_uncors.go index 7c0e0ca2..5652f6bb 100644 --- a/internal/cli/run_uncors.go +++ b/internal/cli/run_uncors.go @@ -3,6 +3,7 @@ package cli import ( "context" "errors" + "fmt" "os" "github.com/evg4b/uncors/internal/config" @@ -26,15 +27,16 @@ func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { uncorsConfig, path, err := config.LoadConfiguration(fs, Version, args) if err != nil { if errors.Is(err, config.ErrVersionRequested) { - println(container.Version()) + fmt.Fprintln(os.Stdout, container.Version()) + return nil } - if !errors.Is(err, pflag.ErrHelp) && !errors.Is(err, config.ErrVersionRequested) { - return err + if errors.Is(err, pflag.ErrHelp) { + return nil } - return nil + return err } var runError error diff --git a/internal/config/config.go b/internal/config/config.go index 4abcc172..a7b6ece4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ func LoadConfiguration(fs afero.Fs, version string, args []string) (*UncorsConfi if err != nil { return nil, "", err } + if printVersion { return nil, "", ErrVersionRequested } From 5180d0bd0620c33c0151668f85157459943f1c68 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 13:58:31 -0400 Subject: [PATCH 24/30] test: add coverage for --version, --help, and ErrVersionRequested paths - RunUncors: add tests for --version (prints version, returns nil) and --help (returns nil via pflag.ErrHelp) - GenerateCerts: add test for --help flag returning nil - LoadConfiguration: add test confirming --version returns ErrVersionRequested Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/generate_certs_test.go | 5 +++++ internal/cli/run_uncors_test.go | 10 ++++++++++ internal/config/config_test.go | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/internal/cli/generate_certs_test.go b/internal/cli/generate_certs_test.go index 5e4b932d..77693662 100644 --- a/internal/cli/generate_certs_test.go +++ b/internal/cli/generate_certs_test.go @@ -20,4 +20,9 @@ func TestGenerateCerts(t *testing.T) { err := cli.GenerateCerts([]string{"--validity-days=7"}) require.NoError(t, err) }) + + t.Run("returns nil for --help flag", func(t *testing.T) { + err := cli.GenerateCerts([]string{"--help"}) + require.NoError(t, err) + }) } diff --git a/internal/cli/run_uncors_test.go b/internal/cli/run_uncors_test.go index 966d1657..c852936b 100644 --- a/internal/cli/run_uncors_test.go +++ b/internal/cli/run_uncors_test.go @@ -97,6 +97,16 @@ func TestRunUncors(t *testing.T) { require.Error(t, err) }) + t.Run("returns nil for --version flag", func(t *testing.T) { + err := cli.RunUncors(context.Background(), afero.NewMemMapFs(), []string{"--version"}) + require.NoError(t, err) + }) + + t.Run("returns nil for --help flag", func(t *testing.T) { + err := cli.RunUncors(context.Background(), afero.NewMemMapFs(), []string{"--help"}) + require.NoError(t, err) + }) + t.Run("non-interactive: starts server and shuts down on context cancellation", func(t *testing.T) { cfg, port := httpMapping(t) fs := afero.NewMemMapFs() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 614b3cfa..6db69077 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -336,6 +336,11 @@ func TestLoadConfiguration(t *testing.T) { }) } +func TestLoadConfiguration_VersionFlag(t *testing.T) { + _, _, err := config.LoadConfiguration(afero.NewMemMapFs(), "1.2.3", []string{"--version"}) + require.ErrorIs(t, err, config.ErrVersionRequested) +} + func TestUncorsConfigValidator(t *testing.T) { mapFs := testutils.FsFromMap(t, map[string]string{}) From a6c7992f57044a1c58a25504634ad6b56c6d8fe0 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 14:11:36 -0400 Subject: [PATCH 25/30] Extract container in first level --- internal/cli/generate_certs.go | 17 +++------------- internal/cli/generate_certs_test.go | 7 ++++--- internal/cli/run_ineractive.go | 3 +-- internal/cli/run_non_ineractive.go | 7 +++---- internal/cli/run_uncors.go | 22 +++++--------------- internal/cli/run_uncors_test.go | 13 +++++++----- internal/cli/version.go | 3 --- internal/di/container.go | 8 ++++++++ internal/di/override.go | 6 ++---- internal/di/public_api.go | 4 ++++ internal/di/public_api_test.go | 6 +++--- internal/uncors_app/app.go | 2 +- main.go | 31 +++++++++++++++++++++++------ testing/integration/proxy.go | 9 ++++++++- 14 files changed, 75 insertions(+), 63 deletions(-) delete mode 100644 internal/cli/version.go diff --git a/internal/cli/generate_certs.go b/internal/cli/generate_certs.go index b954b2fd..d5baf26b 100644 --- a/internal/cli/generate_certs.go +++ b/internal/cli/generate_certs.go @@ -2,31 +2,20 @@ package cli import ( "errors" - "os" "github.com/evg4b/uncors/internal/di" - "github.com/spf13/afero" "github.com/spf13/pflag" ) const GenerateCertsCmd = "generate-certs" -func GenerateCerts(args []string) error { - fs := afero.NewOsFs() - - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - di.WithVersion(Version), - ) - defer container.Close() - +func GenerateCerts(container *di.Container) error { cmd := container.GenerateCertsCommand() flags := pflag.NewFlagSet(GenerateCertsCmd, pflag.ContinueOnError) - cmd.DefineFlags(flags, Version) + cmd.DefineFlags(flags, container.Version()) - err := flags.Parse(args) + err := flags.Parse(container.Args()) if err != nil { if !errors.Is(err, pflag.ErrHelp) { return err diff --git a/internal/cli/generate_certs_test.go b/internal/cli/generate_certs_test.go index 77693662..da4c33e1 100644 --- a/internal/cli/generate_certs_test.go +++ b/internal/cli/generate_certs_test.go @@ -4,12 +4,13 @@ import ( "testing" "github.com/evg4b/uncors/internal/cli" + "github.com/evg4b/uncors/internal/di" "github.com/stretchr/testify/require" ) func TestGenerateCerts(t *testing.T) { t.Run("returns error for unknown flag", func(t *testing.T) { - err := cli.GenerateCerts([]string{"--unknown-flag"}) + err := cli.GenerateCerts(di.NewContainer(di.WithArgs([]string{"--unknown-flag"}))) require.Error(t, err) }) @@ -17,12 +18,12 @@ func TestGenerateCerts(t *testing.T) { // Point HOME to a temp dir so certs go there, not ~/.config/uncors. t.Setenv("HOME", t.TempDir()) - err := cli.GenerateCerts([]string{"--validity-days=7"}) + err := cli.GenerateCerts(di.NewContainer(di.WithArgs([]string{"--validity-days=7"}))) require.NoError(t, err) }) t.Run("returns nil for --help flag", func(t *testing.T) { - err := cli.GenerateCerts([]string{"--help"}) + err := cli.GenerateCerts(di.NewContainer(di.WithArgs([]string{"--help"}))) require.NoError(t, err) }) } diff --git a/internal/cli/run_ineractive.go b/internal/cli/run_ineractive.go index 69030f87..733b1721 100644 --- a/internal/cli/run_ineractive.go +++ b/internal/cli/run_ineractive.go @@ -14,14 +14,13 @@ func runIneractive( container *di.Container, cfg *config.UncorsConfig, cfgPath string, - args []string, ) error { app := uncor.NewUncorsApp( container, cfgPath, cfg, func() *config.UncorsConfig { - reloaded, _, _ := config.LoadConfiguration(container.Fs(), Version, args) + reloaded, _, _ := config.LoadConfiguration(container.Fs(), container.Version(), container.Args()) return reloaded }, diff --git a/internal/cli/run_non_ineractive.go b/internal/cli/run_non_ineractive.go index e931bada..571e22f8 100644 --- a/internal/cli/run_non_ineractive.go +++ b/internal/cli/run_non_ineractive.go @@ -21,7 +21,6 @@ func runNonIneractive( container *di.Container, cfg *config.UncorsConfig, cfgPath string, - args []string, ) error { output := container.CliOutput() tui.PrintLogo(output, container.Version()) @@ -48,7 +47,7 @@ func runNonIneractive( go func() { watcher := config.NewWatcher(cfgPath) - err := watcher.Watch(ctx, func() { reloadServer(ctx, container, srv, args) }) + err := watcher.Watch(ctx, func() { reloadServer(ctx, container, srv) }) if err != nil { output.Error(err) } @@ -82,10 +81,10 @@ func runNonIneractive( return nil } -func reloadServer(ctx context.Context, container *di.Container, srv *server.Server, args []string) { +func reloadServer(ctx context.Context, container *di.Container, srv *server.Server) { output := container.CliOutput() - newUncorsConfig, _, err := config.LoadConfiguration(container.Fs(), Version, args) + newUncorsConfig, _, err := config.LoadConfiguration(container.Fs(), container.Version(), container.Args()) if err != nil { output.Error(err) diff --git a/internal/cli/run_uncors.go b/internal/cli/run_uncors.go index 5652f6bb..135fc1f5 100644 --- a/internal/cli/run_uncors.go +++ b/internal/cli/run_uncors.go @@ -8,23 +8,11 @@ import ( "github.com/evg4b/uncors/internal/config" "github.com/evg4b/uncors/internal/di" - "github.com/spf13/afero" "github.com/spf13/pflag" ) -func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { - var containerError error - - container := di.NewContainer( - di.WithFs(fs), - di.WithStdout(os.Stdout), - di.WithVersion(Version), - ) - defer func() { - containerError = container.Close() - }() - - uncorsConfig, path, err := config.LoadConfiguration(fs, Version, args) +func RunUncors(ctx context.Context, container *di.Container) error { + uncorsConfig, path, err := config.LoadConfiguration(container.Fs(), container.Version(), container.Args()) if err != nil { if errors.Is(err, config.ErrVersionRequested) { fmt.Fprintln(os.Stdout, container.Version()) @@ -41,14 +29,14 @@ func RunUncors(ctx context.Context, fs afero.Fs, args []string) error { var runError error if uncorsConfig.Interactive { - runError = runIneractive(ctx, container, uncorsConfig, path, args) + runError = runIneractive(ctx, container, uncorsConfig, path) } else { - runError = runNonIneractive(ctx, container, uncorsConfig, path, args) + runError = runNonIneractive(ctx, container, uncorsConfig, path) } if runError != nil && !errors.Is(runError, pflag.ErrHelp) { return runError } - return containerError + return nil } diff --git a/internal/cli/run_uncors_test.go b/internal/cli/run_uncors_test.go index c852936b..ca47fd26 100644 --- a/internal/cli/run_uncors_test.go +++ b/internal/cli/run_uncors_test.go @@ -12,6 +12,7 @@ import ( "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/testing/hosts" "github.com/evg4b/uncors/testing/testutils" "github.com/spf13/afero" @@ -84,7 +85,7 @@ func startProxy(ctx context.Context, fs afero.Fs, args []string) <-chan error { errCh := make(chan error, 1) go func() { - errCh <- cli.RunUncors(ctx, fs, args) + errCh <- cli.RunUncors(ctx, di.NewContainer(di.WithFs(fs), di.WithArgs(args))) }() return errCh @@ -93,17 +94,17 @@ func startProxy(ctx context.Context, fs afero.Fs, args []string) <-chan error { func TestRunUncors(t *testing.T) { t.Run("returns error when LoadConfiguration fails", func(t *testing.T) { // No --from/--to flags and no config file → "mappings must not be empty" - err := cli.RunUncors(context.Background(), afero.NewMemMapFs(), []string{}) + err := cli.RunUncors(context.Background(), di.NewContainer(di.WithArgs([]string{}))) require.Error(t, err) }) t.Run("returns nil for --version flag", func(t *testing.T) { - err := cli.RunUncors(context.Background(), afero.NewMemMapFs(), []string{"--version"}) + err := cli.RunUncors(context.Background(), di.NewContainer(di.WithArgs([]string{"--version"}))) require.NoError(t, err) }) t.Run("returns nil for --help flag", func(t *testing.T) { - err := cli.RunUncors(context.Background(), afero.NewMemMapFs(), []string{"--help"}) + err := cli.RunUncors(context.Background(), di.NewContainer(di.WithArgs([]string{"--help"}))) require.NoError(t, err) }) @@ -147,7 +148,9 @@ func TestRunUncors(t *testing.T) { require.NoError(t, err) require.NoError(t, afero.WriteFile(fs, "/config.yaml", data, 0o600)) - err = cli.RunUncors(context.Background(), fs, []string{"-c", "/config.yaml", "--interactive=false"}) + container := di.NewContainer(di.WithFs(fs), di.WithArgs([]string{"-c", "/config.yaml", "--interactive=false"})) + + err = cli.RunUncors(context.Background(), container) require.Error(t, err) }) diff --git a/internal/cli/version.go b/internal/cli/version.go deleted file mode 100644 index 166fef8d..00000000 --- a/internal/cli/version.go +++ /dev/null @@ -1,3 +0,0 @@ -package cli - -var Version = "v0.0.0" diff --git a/internal/di/container.go b/internal/di/container.go index 80cd654b..d4aad29d 100644 --- a/internal/di/container.go +++ b/internal/di/container.go @@ -15,6 +15,7 @@ import ( type Container struct { fs afero.Fs stdout io.Writer + args []string version string cliOutput factory[contracts.Output] @@ -47,12 +48,19 @@ func WithFs(fs afero.Fs) ContainerOption { } } +func WithArgs(args []string) ContainerOption { + return func(c *Container) { + c.args = args + } +} + func NewContainer(options ...ContainerOption) *Container { container := &Container{ fs: afero.NewMemMapFs(), stdout: io.Discard, version: "0.0.0", closers: []io.Closer{}, + args: []string{}, } container = helpers.ApplyOptions(container, options) diff --git a/internal/di/override.go b/internal/di/override.go index 1634e50c..36d0cd02 100644 --- a/internal/di/override.go +++ b/internal/di/override.go @@ -2,13 +2,11 @@ package di import "github.com/evg4b/uncors/internal/contracts" -type OverrideFunc func(c *Container) - -func (c *Container) Override(action OverrideFunc) { +func (c *Container) Override(action ContainerOption) { action(c) } -func OverrideCliOutput(factory func() contracts.Output) OverrideFunc { +func WithCliOutput(factory func() contracts.Output) ContainerOption { return func(c *Container) { c.cliOutput = newFactory(factory) } diff --git a/internal/di/public_api.go b/internal/di/public_api.go index 223b7998..a9381563 100644 --- a/internal/di/public_api.go +++ b/internal/di/public_api.go @@ -27,6 +27,10 @@ import ( "github.com/spf13/afero" ) +func (c *Container) Args() []string { + return c.args +} + func (c *Container) Fs() afero.Fs { return c.fs } diff --git a/internal/di/public_api_test.go b/internal/di/public_api_test.go index bed48628..f6675d95 100644 --- a/internal/di/public_api_test.go +++ b/internal/di/public_api_test.go @@ -253,7 +253,7 @@ func TestContainerOverride(t *testing.T) { overrideApplied := false - container.Override(di.OverrideCliOutput(func() contracts.Output { + container.Override(di.WithCliOutput(func() contracts.Output { overrideApplied = true return customOutput @@ -262,7 +262,7 @@ func TestContainerOverride(t *testing.T) { newContainer := di.NewContainer() defer testutils.Close(t, newContainer) - newContainer.Override(di.OverrideCliOutput(func() contracts.Output { + newContainer.Override(di.WithCliOutput(func() contracts.Output { return customOutput })) @@ -278,7 +278,7 @@ func TestContainerOverride(t *testing.T) { sentinel := container.CliOutput() - container.Override(di.OverrideCliOutput(func() contracts.Output { + container.Override(di.WithCliOutput(func() contracts.Output { return sentinel })) diff --git a/internal/uncors_app/app.go b/internal/uncors_app/app.go index ec75e30f..8cfe0fcb 100644 --- a/internal/uncors_app/app.go +++ b/internal/uncors_app/app.go @@ -78,7 +78,7 @@ func NewUncorsApp( appCtx, cancel := context.WithCancel(context.Background()) - container.Override(di.OverrideCliOutput(func() contracts.Output { + container.Override(di.WithCliOutput(func() contracts.Output { return output })) diff --git a/main.go b/main.go index 06a657f5..d277fe7d 100644 --- a/main.go +++ b/main.go @@ -5,27 +5,46 @@ import ( "os" "github.com/evg4b/uncors/internal/cli" + "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/internal/infra" "github.com/evg4b/uncors/internal/tui" "github.com/spf13/afero" ) +const Version = "v0.0.0" + func main() { infra.SetupLogging() + container := di.NewContainer( + di.WithFs(afero.NewOsFs()), + di.WithStdout(os.Stdout), + di.WithVersion(Version), + ) + + defer func() { + handleError(container.Close()) + }() + if len(os.Args) >= 2 && os.Args[1] == cli.GenerateCertsCmd { - err := cli.GenerateCerts(os.Args[2:]) - if err != nil { - tui.NewCliOutput(os.Stdout). - Error(err) - } + container.Override(di.WithArgs(os.Args[2:])) + + err := cli.GenerateCerts(container) + handleError(err) return } - err := cli.RunUncors(context.Background(), afero.NewOsFs(), os.Args[1:]) + container.Override(di.WithArgs(os.Args[1:])) + err := cli.RunUncors(context.Background(), container) + handleError(err) +} + +func handleError(err error) { if err != nil { tui.NewCliOutput(os.Stdout). Error(err) + + os.Exit(1) } } diff --git a/testing/integration/proxy.go b/testing/integration/proxy.go index 89430141..2468cf72 100644 --- a/testing/integration/proxy.go +++ b/testing/integration/proxy.go @@ -12,8 +12,11 @@ import ( "github.com/evg4b/uncors/internal/cli" "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/di" "github.com/evg4b/uncors/internal/server" + "github.com/evg4b/uncors/testing/testutils" "github.com/spf13/afero" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) @@ -58,7 +61,11 @@ func bootProxy(t *testing.T, fs afero.Fs, cfg *config.UncorsConfig) *x509.Certif go func() { // --interactive=false overrides the default (true) so the proxy runs // in headless mode and actually starts its TCP listeners. - _ = cli.RunUncors(t.Context(), fs, []string{"-c", configPath, "--interactive=false"}) + container := di.NewContainer(di.WithFs(fs), di.WithArgs([]string{"-c", configPath, "--interactive=false"})) + defer testutils.Close(t, container) + + err = cli.RunUncors(t.Context(), container) + assert.NoError(t, err) }() waitForMappings(t, cfg) From 50f7c6e85b4bdc4b507830bbcd7ae4349bf88909 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 14:15:03 -0400 Subject: [PATCH 26/30] Cleanup code --- .goreleaser.yaml | 2 +- Makefile | 2 +- internal/cli/run_uncors_test.go | 23 +++++++++++++++++++---- internal/di/container.go | 3 ++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 913e633f..b827d9f0 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -21,7 +21,7 @@ builds: goarch: '386' main: '.' ldflags: - - -s -w -X github.com/evg4b/uncors/internal/cli.Version={{ .Version }} + - -s -w -X main.Version={{ .Version }} tags: [ release ] checksum: name_template: 'checksums.txt' diff --git a/Makefile b/Makefile index 3803e6a0..9ce26351 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ GOTEST := $(GO) test GOBUILD := $(GO) build GOINSTALL := $(GO) install VERSION := $(shell git rev-parse --short HEAD) -LDFLAGS := -ldflags="-s -w -X 'github.com/evg4b/uncors/internal/cli.Version=$(VERSION)'" +LDFLAGS := -ldflags="-s -w -X 'main.Version=$(VERSION)'" COVERAGE_FILE := coverage.out BINARY_NAME := uncors BINARY_WINDOWS := $(BINARY_NAME).exe diff --git a/internal/cli/run_uncors_test.go b/internal/cli/run_uncors_test.go index ca47fd26..14101e4d 100644 --- a/internal/cli/run_uncors_test.go +++ b/internal/cli/run_uncors_test.go @@ -85,7 +85,12 @@ func startProxy(ctx context.Context, fs afero.Fs, args []string) <-chan error { errCh := make(chan error, 1) go func() { - errCh <- cli.RunUncors(ctx, di.NewContainer(di.WithFs(fs), di.WithArgs(args))) + container := di.NewContainer(di.WithFs(fs), di.WithArgs(args)) + defer func() { + errCh <- container.Close() + }() + + errCh <- cli.RunUncors(ctx, container) }() return errCh @@ -94,17 +99,26 @@ func startProxy(ctx context.Context, fs afero.Fs, args []string) <-chan error { func TestRunUncors(t *testing.T) { t.Run("returns error when LoadConfiguration fails", func(t *testing.T) { // No --from/--to flags and no config file → "mappings must not be empty" - err := cli.RunUncors(context.Background(), di.NewContainer(di.WithArgs([]string{}))) + container := di.NewContainer(di.WithArgs([]string{})) + defer testutils.Close(t, container) + + err := cli.RunUncors(context.Background(), container) require.Error(t, err) }) t.Run("returns nil for --version flag", func(t *testing.T) { - err := cli.RunUncors(context.Background(), di.NewContainer(di.WithArgs([]string{"--version"}))) + container := di.NewContainer(di.WithArgs([]string{"--version"})) + defer testutils.Close(t, container) + + err := cli.RunUncors(context.Background(), container) require.NoError(t, err) }) t.Run("returns nil for --help flag", func(t *testing.T) { - err := cli.RunUncors(context.Background(), di.NewContainer(di.WithArgs([]string{"--help"}))) + container := di.NewContainer(di.WithArgs([]string{"--help"})) + defer testutils.Close(t, container) + + err := cli.RunUncors(context.Background(), container) require.NoError(t, err) }) @@ -149,6 +163,7 @@ func TestRunUncors(t *testing.T) { require.NoError(t, afero.WriteFile(fs, "/config.yaml", data, 0o600)) container := di.NewContainer(di.WithFs(fs), di.WithArgs([]string{"-c", "/config.yaml", "--interactive=false"})) + defer testutils.Close(t, container) err = cli.RunUncors(context.Background(), container) require.Error(t, err) diff --git a/internal/di/container.go b/internal/di/container.go index d4aad29d..ca631995 100644 --- a/internal/di/container.go +++ b/internal/di/container.go @@ -3,6 +3,7 @@ package di import ( "errors" "io" + "os" "github.com/evg4b/uncors/internal/commands" "github.com/evg4b/uncors/internal/config" @@ -60,7 +61,7 @@ func NewContainer(options ...ContainerOption) *Container { stdout: io.Discard, version: "0.0.0", closers: []io.Closer{}, - args: []string{}, + args: os.Args, } container = helpers.ApplyOptions(container, options) From 5a6e19fbc6d0f553d841c1f6d79d50d3cff288f3 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 14:17:06 -0400 Subject: [PATCH 27/30] fix: replace os.Exit-triggering tests with version/help flag paths TestMain_RunUncorsErrorPath called main() with invalid args causing handleError to call os.Exit(1), which kills the test process. Similarly TestMain_GenerateCertsErrorPath triggered os.Exit via --unknown-flag. Replace both with --version and --help paths that return nil and exit main() normally, testing the same main() control flow without crashing the test runner. Co-Authored-By: Claude Sonnet 4.6 --- main_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/main_test.go b/main_test.go index 72ba7abc..10d20858 100644 --- a/main_test.go +++ b/main_test.go @@ -65,10 +65,10 @@ func TestSetupLogging(t *testing.T) { }) } -func TestMain_RunUncorsErrorPath(t *testing.T) { +func TestMain_RunUncorsVersionPath(t *testing.T) { saveLogger(t) - // Test args are not valid uncors config, so RunUncors returns an error. - // main() must swallow it gracefully (no panic). + setArgs(t, []string{"uncors", "--version"}) + assert.NotPanics(t, func() { main() }) @@ -85,9 +85,9 @@ func TestMain_GenerateCertsPath(t *testing.T) { }) } -func TestMain_GenerateCertsErrorPath(t *testing.T) { +func TestMain_GenerateCertsHelpPath(t *testing.T) { saveLogger(t) - setArgs(t, []string{"uncors", cli.GenerateCertsCmd, "--unknown-flag"}) + setArgs(t, []string{"uncors", cli.GenerateCertsCmd, "--help"}) assert.NotPanics(t, func() { main() From 853b98abfc459cca599da1abf8239d300d273976 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 14:21:29 -0400 Subject: [PATCH 28/30] fix: inject osExit to enable testing handleError without killing the process Extract os.Exit into a package-level var so tests can replace it. Add TestHandleError_ExitsOnError and TestHandleError_NoopOnNil to cover the previously uncovered error branch, restoring coverage above 80%. Co-Authored-By: Claude Sonnet 4.6 --- main.go | 4 +++- main_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index d277fe7d..69b08f8a 100644 --- a/main.go +++ b/main.go @@ -40,11 +40,13 @@ func main() { handleError(err) } +var osExit = os.Exit + func handleError(err error) { if err != nil { tui.NewCliOutput(os.Stdout). Error(err) - os.Exit(1) + osExit(1) } } diff --git a/main_test.go b/main_test.go index 10d20858..ebeefff1 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "io" "log" "os" @@ -65,6 +66,36 @@ func TestSetupLogging(t *testing.T) { }) } +var errTest = errors.New("something went wrong") + +func TestHandleError_ExitsOnError(t *testing.T) { + orig := osExit + + var capturedCode int + + osExit = func(code int) { capturedCode = code } + + t.Cleanup(func() { osExit = orig }) + + handleError(errTest) + + assert.Equal(t, 1, capturedCode) +} + +func TestHandleError_NoopOnNil(t *testing.T) { + orig := osExit + + called := false + + osExit = func(_ int) { called = true } + + t.Cleanup(func() { osExit = orig }) + + handleError(nil) + + assert.False(t, called) +} + func TestMain_RunUncorsVersionPath(t *testing.T) { saveLogger(t) setArgs(t, []string{"uncors", "--version"}) From e632ca91aaccd5265c9dece1f376472c5b5c8b95 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Sun, 28 Jun 2026 14:26:30 -0400 Subject: [PATCH 29/30] Added integration tests --- testing/integration/bin.go | 57 +++++++++++++++++++++++++++++++ tests/integration/cli/cli_test.go | 28 +++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 testing/integration/bin.go create mode 100644 tests/integration/cli/cli_test.go diff --git a/testing/integration/bin.go b/testing/integration/bin.go new file mode 100644 index 00000000..5779d3c4 --- /dev/null +++ b/testing/integration/bin.go @@ -0,0 +1,57 @@ +package integration + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +var ( + bin string + compile sync.Once +) + +var ( + repoRoot string + repoRootOnce sync.Once +) + +const UncorsTestVrsion = "v1.2.3" + +func UncorsCommand(t *testing.T, args []string) *exec.Cmd { + compile.Do(func() { + tmp := t.TempDir() + bin = filepath.Join(tmp, "uncors") + cmd := exec.Command( + "go", + "build", + "-o", bin, + "-ldflags", + fmt.Sprintf("-s -w -X 'main.Version=%s'", UncorsTestVrsion), + RepoRoot(t), + ) + _, err := cmd.CombinedOutput() + if err != nil { + panic(err) + } + }) + + return exec.CommandContext(t.Context(), bin, args...) +} + +func RepoRoot(t *testing.T) string { + + t.Helper() + repoRootOnce.Do(func() { + out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output() + if err != nil { + t.Fatalf("failed to determine repository root: %v", err) + } + repoRoot = strings.TrimSpace(string(out)) + }) + return repoRoot + +} diff --git a/tests/integration/cli/cli_test.go b/tests/integration/cli/cli_test.go new file mode 100644 index 00000000..94b57731 --- /dev/null +++ b/tests/integration/cli/cli_test.go @@ -0,0 +1,28 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/evg4b/uncors/testing/integration" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersion(t *testing.T) { + t.Run("short", func(t *testing.T) { + cmd := integration.UncorsCommand(t, []string{"-v"}) + bytes, err := cmd.CombinedOutput() + require.NoError(t, err) + + assert.Equal(t, fmt.Sprintf("%s\n", integration.UncorsTestVrsion), string(bytes)) + }) + + t.Run("full", func(t *testing.T) { + cmd := integration.UncorsCommand(t, []string{"--version"}) + bytes, err := cmd.CombinedOutput() + require.NoError(t, err) + + assert.Equal(t, fmt.Sprintf("%s\n", integration.UncorsTestVrsion), string(bytes)) + }) +} From adeac374cf67d9bf56baee27851e1853d4cbb6d2 Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 29 Jun 2026 14:56:06 -0400 Subject: [PATCH 30/30] Fixed tests --- main.go | 2 +- testing/integration/bin.go | 42 ++++++++++++++++++++---------- tests/integration/cli/main_test.go | 13 +++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 tests/integration/cli/main_test.go diff --git a/main.go b/main.go index 69b08f8a..caa42437 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/afero" ) -const Version = "v0.0.0" +var Version = "v0.0.0" func main() { infra.SetupLogging() diff --git a/testing/integration/bin.go b/testing/integration/bin.go index 5779d3c4..74a632f1 100644 --- a/testing/integration/bin.go +++ b/testing/integration/bin.go @@ -1,7 +1,9 @@ package integration import ( + "context" "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -21,37 +23,49 @@ var ( const UncorsTestVrsion = "v1.2.3" -func UncorsCommand(t *testing.T, args []string) *exec.Cmd { +func SetupBin(_ *testing.M) { compile.Do(func() { - tmp := t.TempDir() + //nolint:usetesting // intentional: binary lifetime must span all tests, not one subtest + tmp, err := os.MkdirTemp("", "uncors-test-*") + if err != nil { + panic(err) + } + bin = filepath.Join(tmp, "uncors") - cmd := exec.Command( - "go", - "build", + cmd := exec.CommandContext( + context.Background(), + "go", "build", "-o", bin, - "-ldflags", - fmt.Sprintf("-s -w -X 'main.Version=%s'", UncorsTestVrsion), - RepoRoot(t), + "-ldflags", fmt.Sprintf("-s -w -X 'main.Version=%s'", UncorsTestVrsion), + repoRootPath(), ) - _, err := cmd.CombinedOutput() + + _, err = cmd.CombinedOutput() if err != nil { panic(err) } }) +} +func UncorsCommand(t *testing.T, args []string) *exec.Cmd { return exec.CommandContext(t.Context(), bin, args...) } -func RepoRoot(t *testing.T) string { - - t.Helper() +func repoRootPath() string { repoRootOnce.Do(func() { - out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output() + out, err := exec.CommandContext(context.Background(), "go", "list", "-m", "-f", "{{.Dir}}").Output() if err != nil { - t.Fatalf("failed to determine repository root: %v", err) + panic(fmt.Sprintf("failed to determine repository root: %v", err)) } + repoRoot = strings.TrimSpace(string(out)) }) + return repoRoot +} + +func RepoRoot(t *testing.T) string { + t.Helper() + return repoRootPath() } diff --git a/tests/integration/cli/main_test.go b/tests/integration/cli/main_test.go new file mode 100644 index 00000000..e35d4154 --- /dev/null +++ b/tests/integration/cli/main_test.go @@ -0,0 +1,13 @@ +package cli_test + +import ( + "os" + "testing" + + "github.com/evg4b/uncors/testing/integration" +) + +func TestMain(m *testing.M) { + integration.SetupBin(m) + os.Exit(m.Run()) +}