From 50a287d5099f7c5aaab0390465b29b666fda75ea Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 28 May 2026 13:43:44 +0300 Subject: [PATCH 1/2] Prompt for login before emulator selection on first run --- internal/ui/run.go | 31 ++++++++++++++++ internal/ui/run_test.go | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/ui/run_test.go diff --git a/internal/ui/run.go b/internal/ui/run.go index 6ecc5c78..d2825783 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -6,6 +6,7 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" @@ -75,6 +76,19 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p.Send(runDoneMsg{}) return } + // Resolve the auth token before any emulator-selection prompt so the user + // logs in first and only configures an emulator once they're authenticated. + // container.Start still calls GetToken as a safety net for non-interactive + // callers; once the token is in opts.AuthToken (or the keyring), it returns + // immediately. + if authErr := resolveAuthToken(ctx, sink, &runOpts); authErr != nil { + if errors.Is(authErr, context.Canceled) { + return + } + err = authErr + p.Send(runErrMsg{err: authErr}) + return + } if runOpts.NeedsEmulatorSelection { newContainers, selErr := container.SelectEmulator(ctx, sink, runOpts.ConfigPath) if selErr != nil { @@ -115,6 +129,23 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return nil } +// resolveAuthToken ensures the user is authenticated before the start flow +// continues. On success, the resolved token is written to opts.StartOptions.AuthToken +// so container.Start short-circuits its own auth call. +func resolveAuthToken(ctx context.Context, sink output.Sink, opts *RunOptions) error { + tokenStorage, err := auth.NewTokenStorage(opts.StartOptions.ForceFileKeyring, opts.StartOptions.Logger) + if err != nil { + return err + } + a := auth.New(sink, opts.StartOptions.PlatformClient, tokenStorage, opts.StartOptions.AuthToken, opts.StartOptions.WebAppURL, true, "") + token, err := a.GetToken(ctx) + if err != nil { + return err + } + opts.StartOptions.AuthToken = token + return nil +} + func RunMessage(parentCtx context.Context, event output.MessageEvent) error { return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { sink.Emit(event) diff --git a/internal/ui/run_test.go b/internal/ui/run_test.go new file mode 100644 index 00000000..23840c18 --- /dev/null +++ b/internal/ui/run_test.go @@ -0,0 +1,81 @@ +package ui + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/localstack/lstk/internal/api" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveAuthThenSelectEmulator_OrdersAuthFirst verifies that on first run +// with no token, the login prompt fires before the emulator-selection prompt. +// This is the property that lets users authenticate before configuring an +// emulator instead of the other way around. +func TestResolveAuthThenSelectEmulator_OrdersAuthFirst(t *testing.T) { + mockServer := createMockAPIServer(t, "test-license-token", true) + defer mockServer.Close() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + configPath := filepath.Join(home, "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`[[containers]] +type = "aws" +port = "4566" +`), 0644)) + require.NoError(t, config.InitFromPath(configPath)) + t.Cleanup(func() { viper.Reset() }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var mu sync.Mutex + var prompts []string + sink := output.SinkFunc(func(ev output.Event) { + req, ok := ev.(output.UserInputRequestEvent) + if !ok { + return + } + mu.Lock() + prompts = append(prompts, req.Prompt) + mu.Unlock() + req.ResponseCh <- output.InputResponse{SelectedKey: req.Options[0].Key} + }) + + platformClient := api.NewPlatformClient(mockServer.URL, log.Nop()) + opts := RunOptions{ + StartOptions: container.StartOptions{ + PlatformClient: platformClient, + ForceFileKeyring: true, + WebAppURL: mockServer.URL, + Logger: log.Nop(), + }, + } + + require.NoError(t, resolveAuthToken(ctx, sink, &opts)) + assert.NotEmpty(t, opts.StartOptions.AuthToken, "token should be populated on opts after login") + + _, err := container.SelectEmulator(ctx, sink, "") + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + require.GreaterOrEqual(t, len(prompts), 2, "expected at least the auth and emulator prompts") + assert.True(t, strings.Contains(strings.ToLower(prompts[0]), "authorization"), + "first prompt should be the login flow, got %q", prompts[0]) + assert.True(t, strings.Contains(strings.ToLower(prompts[1]), "emulator"), + "second prompt should be emulator selection, got %q", prompts[1]) +} From 7000c71d910430b8399bbfcbc1506706f2ddac88 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 28 May 2026 16:07:58 +0300 Subject: [PATCH 2/2] Replace auth-before-emulator unit test with an integration test Co-Authored-By: Claude Opus 4.7 --- internal/ui/run_test.go | 81 ------------------------ test/integration/emulator_select_test.go | 56 ++++++++++++++++ 2 files changed, 56 insertions(+), 81 deletions(-) delete mode 100644 internal/ui/run_test.go diff --git a/internal/ui/run_test.go b/internal/ui/run_test.go deleted file mode 100644 index 23840c18..00000000 --- a/internal/ui/run_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package ui - -import ( - "context" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/localstack/lstk/internal/api" - "github.com/localstack/lstk/internal/config" - "github.com/localstack/lstk/internal/container" - "github.com/localstack/lstk/internal/log" - "github.com/localstack/lstk/internal/output" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestResolveAuthThenSelectEmulator_OrdersAuthFirst verifies that on first run -// with no token, the login prompt fires before the emulator-selection prompt. -// This is the property that lets users authenticate before configuring an -// emulator instead of the other way around. -func TestResolveAuthThenSelectEmulator_OrdersAuthFirst(t *testing.T) { - mockServer := createMockAPIServer(t, "test-license-token", true) - defer mockServer.Close() - - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - - configPath := filepath.Join(home, "config.toml") - require.NoError(t, os.WriteFile(configPath, []byte(`[[containers]] -type = "aws" -port = "4566" -`), 0644)) - require.NoError(t, config.InitFromPath(configPath)) - t.Cleanup(func() { viper.Reset() }) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - var mu sync.Mutex - var prompts []string - sink := output.SinkFunc(func(ev output.Event) { - req, ok := ev.(output.UserInputRequestEvent) - if !ok { - return - } - mu.Lock() - prompts = append(prompts, req.Prompt) - mu.Unlock() - req.ResponseCh <- output.InputResponse{SelectedKey: req.Options[0].Key} - }) - - platformClient := api.NewPlatformClient(mockServer.URL, log.Nop()) - opts := RunOptions{ - StartOptions: container.StartOptions{ - PlatformClient: platformClient, - ForceFileKeyring: true, - WebAppURL: mockServer.URL, - Logger: log.Nop(), - }, - } - - require.NoError(t, resolveAuthToken(ctx, sink, &opts)) - assert.NotEmpty(t, opts.StartOptions.AuthToken, "token should be populated on opts after login") - - _, err := container.SelectEmulator(ctx, sink, "") - require.NoError(t, err) - - mu.Lock() - defer mu.Unlock() - require.GreaterOrEqual(t, len(prompts), 2, "expected at least the auth and emulator prompts") - assert.True(t, strings.Contains(strings.ToLower(prompts[0]), "authorization"), - "first prompt should be the login flow, got %q", prompts[0]) - assert.True(t, strings.Contains(strings.ToLower(prompts[1]), "emulator"), - "second prompt should be emulator selection, got %q", prompts[1]) -} diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 62c80931..4e2138a4 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -114,6 +114,62 @@ func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { <-outputCh } +func TestFirstRunPromptsForLoginBeforeEmulatorSelection(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + mockServer := createMockAPIServer(t, "test-license-token", true) + defer mockServer.Close() + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + Without(env.AuthToken). + With(env.APIEndpoint, mockServer.URL). + With(env.DisableEvents, "1") + + // No config exists so this is a first run; no token means login fires before emulator selection. + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e + + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start lstk in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Press any key when complete")) + }, 10*time.Second, 100*time.Millisecond, "auth prompt should appear on first run when no token is set") + + assert.NotContains(t, out.String(), "Which emulator would you like to use?", + "emulator selection prompt must not appear before auth completes") + + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 10*time.Second, 100*time.Millisecond, "emulator selection prompt should appear after auth completes") + + cancel() + <-outputCh +} + func TestFirstRunNonInteractiveEmitsDefaultEmulatorNote(t *testing.T) { t.Parallel() tmpHome := t.TempDir()