Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,25 @@ type Auth struct {
licenseFilePath string
}

func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, authToken, webAppURL string, allowLogin bool, licenseFilePath string) *Auth {
// Option configures optional behavior of the login flow.
type Option func(*loginProvider)

// WithBrowserOpener overrides how the login flow opens the auth URL in a
// browser. Tests inject a recorder so they don't spawn real browser tabs.
func WithBrowserOpener(open func(string) error) Option {
return func(lp *loginProvider) {
lp.openBrowser = open
}
}

func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, authToken, webAppURL string, allowLogin bool, licenseFilePath string, opts ...Option) *Auth {
lp := newLoginProvider(sink, platform, webAppURL)
for _, opt := range opts {
opt(lp)
}
return &Auth{
tokenStorage: storage,
login: newLoginProvider(sink, platform, webAppURL),
login: lp,
sink: sink,
authToken: authToken,
allowLogin: allowLogin,
Expand Down
12 changes: 9 additions & 3 deletions internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ type loginProvider struct {
platformClient api.PlatformAPI
sink output.Sink
webAppURL string
openBrowser func(string) error
}

func defaultOpenBrowser(url string) error {
browser.Stdout = io.Discard
browser.Stderr = io.Discard
return browser.OpenURL(url)
}

func newLoginProvider(sink output.Sink, platformClient api.PlatformAPI, webAppURL string) *loginProvider {
return &loginProvider{
platformClient: platformClient,
sink: sink,
webAppURL: webAppURL,
openBrowser: defaultOpenBrowser,
}
}

Expand All @@ -45,9 +53,7 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) {
Code: authReq.Code,
URL: authURL,
})
browser.Stdout = io.Discard
browser.Stderr = io.Discard
if err := browser.OpenURL(authURL); err != nil {
if err := l.openBrowser(authURL); err != nil {
l.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL)})
}

Expand Down
36 changes: 33 additions & 3 deletions internal/ui/run_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -80,6 +81,26 @@ func readOutput(r io.Reader) string {
return string(b)
}

// browserRecorder captures the URL the login flow would have opened instead of
// spawning a real browser tab.
type browserRecorder struct {
mu sync.Mutex
url string
}

func (b *browserRecorder) open(url string) error {
b.mu.Lock()
defer b.mu.Unlock()
b.url = url
return nil
}

func (b *browserRecorder) opened() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.url
}

func TestLoginFlow_DeviceFlowSuccess(t *testing.T) {
mockServer := createMockAPIServer(t, "test-license-token", true)
defer mockServer.Close()
Expand All @@ -95,10 +116,11 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) {
tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40))
sender := testModelSender{tm: tm}
platformClient := api.NewPlatformClient(mockServer.URL, log.Nop())
recorder := &browserRecorder{}

errCh := make(chan error, 1)
go func() {
a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "")
a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "", auth.WithBrowserOpener(recorder.open))
_, err := a.GetToken(ctx)
errCh <- err
if err != nil && !errors.Is(err, context.Canceled) {
Expand All @@ -112,6 +134,8 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) {
return bytes.Contains(bts, []byte("Waiting for authorization"))
}, teatest.WithDuration(5*time.Second))

assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", recorder.opened())

tm.Send(tea.KeyMsg{Type: tea.KeyEnter})

select {
Expand Down Expand Up @@ -143,10 +167,11 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) {
tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40))
sender := testModelSender{tm: tm}
platformClient := api.NewPlatformClient(mockServer.URL, log.Nop())
recorder := &browserRecorder{}

errCh := make(chan error, 1)
go func() {
a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "")
a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "", auth.WithBrowserOpener(recorder.open))
_, err := a.GetToken(ctx)
errCh <- err
if err != nil && !errors.Is(err, context.Canceled) {
Expand All @@ -160,6 +185,8 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) {
return bytes.Contains(bts, []byte("Waiting for authorization"))
}, teatest.WithDuration(5*time.Second))

assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", recorder.opened())

tm.Send(tea.KeyMsg{Type: tea.KeyEnter})

select {
Expand Down Expand Up @@ -192,10 +219,11 @@ func TestLoginFlow_DeviceFlowCancelWithCtrlC(t *testing.T) {
tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40))
sender := testModelSender{tm: tm}
platformClient := api.NewPlatformClient(mockServer.URL, log.Nop())
recorder := &browserRecorder{}

errCh := make(chan error, 1)
go func() {
a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "")
a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "", auth.WithBrowserOpener(recorder.open))
_, err := a.GetToken(ctx)
errCh <- err
if err != nil && !errors.Is(err, context.Canceled) {
Expand All @@ -209,6 +237,8 @@ func TestLoginFlow_DeviceFlowCancelWithCtrlC(t *testing.T) {
return bytes.Contains(bts, []byte("Waiting for authorization"))
}, teatest.WithDuration(5*time.Second))

assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", recorder.opened())

tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})

select {
Expand Down
2 changes: 2 additions & 0 deletions test/integration/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ const (
AuthToken Key = "LOCALSTACK_AUTH_TOKEN"
LocalStackHost Key = "LOCALSTACK_HOST"
APIEndpoint Key = "LSTK_API_ENDPOINT"
WebAppURL Key = "LSTK_WEB_APP_URL"
Keyring Key = "LSTK_KEYRING"
CI Key = "CI"
AnalyticsEndpoint Key = "LSTK_ANALYTICS_ENDPOINT"
DisableEvents Key = "LOCALSTACK_DISABLE_EVENTS"
Home Key = "HOME"
UserProfile Key = "USERPROFILE"
Path Key = "PATH"
Persistence Key = "LOCALSTACK_PERSISTENCE"
Otel Key = "LSTK_OTEL"
OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT"
Expand Down
30 changes: 28 additions & 2 deletions test/integration/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ func createMockAPIServer(t *testing.T, licenseToken string, confirmed bool) *htt
}))
}

// fakeBrowserOpener prepends a temp dir to PATH containing fake open/xdg-open
// scripts that record the URL they were asked to open instead of spawning a real
// browser tab. It returns the augmented environment and a reader for the recorded
// URL. The scripts cover the providers github.com/pkg/browser tries on macOS
// (open) and Linux (xdg-open, x-www-browser, www-browser).
func fakeBrowserOpener(t *testing.T, environ env.Environ) (env.Environ, func() string) {
t.Helper()
dir := t.TempDir()
record := filepath.Join(dir, "opened-url")
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$1\" > %q\n", record)
for _, name := range []string{"open", "xdg-open", "x-www-browser", "www-browser"} {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755))
}
environ = environ.With(env.Path, dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return environ, func() string {
b, _ := os.ReadFile(record)
return string(b)
}
}

func TestDeviceFlowSuccess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY not supported on Windows")
Expand All @@ -96,8 +116,10 @@ func TestDeviceFlowSuccess(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.WebAppURL, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL))

cmd := exec.CommandContext(ctx, binaryPath(), "login")
cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)
cmd.Env = environ

ptmx, err := pty.Start(cmd)
require.NoError(t, err, "failed to start command in PTY")
Expand All @@ -124,6 +146,7 @@ func TestDeviceFlowSuccess(t *testing.T) {
out := output.String()
require.NoError(t, err, "login should succeed: %s", out)
requireExitCode(t, 0, err)
assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", openedURL(), "login should open the auth URL in a browser")
assert.Contains(t, out, "Opening browser to login...")
assert.Contains(t, out, "Browser didn't open? Visit")
assert.Contains(t, out, "/auth/request/test-auth-req-id?code=TEST123")
Expand Down Expand Up @@ -156,8 +179,10 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.WebAppURL, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL))

cmd := exec.CommandContext(ctx, binaryPath(), "login")
cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)
cmd.Env = environ

ptmx, err := pty.Start(cmd)
require.NoError(t, err, "failed to start command in PTY")
Expand All @@ -184,6 +209,7 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) {
out := output.String()
require.Error(t, err, "expected login to fail when request not confirmed")
requireExitCode(t, 1, err)
assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", openedURL(), "login should open the auth URL in a browser")
assert.Contains(t, out, "Opening browser to login...")
assert.Contains(t, out, "Browser didn't open? Visit")
assert.Contains(t, out, "/auth/request/test-auth-req-id?code=TEST123")
Expand Down
Loading