From 8e10a9bc05799e09dcd9bfd1ac1c001ebff8fa91 Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Wed, 27 May 2026 11:46:55 +0900 Subject: [PATCH 1/2] fix: avoid startup SSO login - skip startup caller identity lookup when SSO login would be required - keep context switching SSO login behavior unchanged - document passive SSO startup behavior Closes #221 --- README.md | 2 + internal/app/app.go | 21 +++++++- internal/app/app_test.go | 104 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 764c8cf..88e0bf0 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ contexts: | `assume_role` | Assume a role from a base profile | `profile`, `role_arn` | | `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `profile`, `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name` | +TUI startup is passive for SSO contexts: it loads the context picker without launching `aws sso login`. SSO login is prompted when you explicitly select or set up an SSO context, or when an AWS-backed workflow needs credentials. + Optional context fields: | Field | Meaning | diff --git a/internal/app/app.go b/internal/app/app.go index 97035e4..869c162 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -271,6 +271,10 @@ type updateAvailableMsg struct { method update.InstallMethod } +var appLoadCallerIdentityFn = func(m Model) tea.Cmd { + return m.loadCallerIdentity() +} + func (m Model) checkForUpdate() tea.Cmd { return func() tea.Msg { method := update.DetectInstallMethod() @@ -280,7 +284,22 @@ func (m Model) checkForUpdate() tea.Cmd { } func (m Model) Init() tea.Cmd { - return tea.Batch(m.loadContexts(), m.checkForUpdate(), m.loadCallerIdentity()) + return tea.Batch(m.loadContexts(), m.checkForUpdate(), m.loadStartupCallerIdentity()) +} + +func (m Model) loadStartupCallerIdentity() tea.Cmd { + return func() tea.Msg { + if m.cfg == nil { + return callerIdentityMsg{} + } + if m.cfg.AuthType == config.AuthTypeSSO { + check, err := contextCheckSSOSessionFn(m.cfg) + if err != nil || check.LoginRequired { + return callerIdentityMsg{} + } + } + return appLoadCallerIdentityFn(m)() + } } func (m Model) loadCallerIdentity() tea.Cmd { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b4acb12..ba74200 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -151,6 +151,110 @@ func TestLoadingSpinnerTickUpdatesOnlyOnLoadingScreen(t *testing.T) { } } +func TestStartupCallerIdentitySkipsInteractiveSSOLoginWhenCacheMissing(t *testing.T) { + origCheck := contextCheckSSOSessionFn + origLoad := appLoadCallerIdentityFn + defer func() { + contextCheckSSOSessionFn = origCheck + appLoadCallerIdentityFn = origLoad + }() + + checkCalled := false + loadCalled := false + contextCheckSSOSessionFn = func(cfg *config.Config) (awsservice.SSOSessionCheck, error) { + checkCalled = true + return awsservice.SSOSessionCheck{ + StartURL: cfg.SSOStartURL, + LoginRequired: true, + }, nil + } + appLoadCallerIdentityFn = func(Model) tea.Cmd { + loadCalled = true + return func() tea.Msg { + return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + } + } + + m := New(&config.Config{ + AuthType: config.AuthTypeSSO, + SSOStartURL: "https://example.awsapps.com/start", + Region: "us-east-1", + }, "", "dev") + + msg := m.loadStartupCallerIdentity()() + if _, ok := msg.(callerIdentityMsg); !ok { + t.Fatalf("expected callerIdentityMsg, got %T", msg) + } + if !checkCalled { + t.Fatal("expected startup to check SSO cache") + } + if loadCalled { + t.Fatal("expected startup to skip identity loading when SSO login is required") + } +} + +func TestStartupCallerIdentityLoadsSSOIdentityWhenCacheValid(t *testing.T) { + origCheck := contextCheckSSOSessionFn + origLoad := appLoadCallerIdentityFn + defer func() { + contextCheckSSOSessionFn = origCheck + appLoadCallerIdentityFn = origLoad + }() + + contextCheckSSOSessionFn = func(cfg *config.Config) (awsservice.SSOSessionCheck, error) { + return awsservice.SSOSessionCheck{StartURL: cfg.SSOStartURL}, nil + } + appLoadCallerIdentityFn = func(Model) tea.Cmd { + return func() tea.Msg { + return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + } + } + + m := New(&config.Config{ + AuthType: config.AuthTypeSSO, + SSOStartURL: "https://example.awsapps.com/start", + Region: "us-east-1", + }, "", "dev") + + msg := m.loadStartupCallerIdentity()() + identityMsg, ok := msg.(callerIdentityMsg) + if !ok { + t.Fatalf("expected callerIdentityMsg, got %T", msg) + } + if identityMsg.identity == nil || identityMsg.identity.Account != "123456789012" { + t.Fatalf("expected loaded identity, got %#v", identityMsg.identity) + } +} + +func TestStartupCallerIdentityStillLoadsNonSSOIdentity(t *testing.T) { + origLoad := appLoadCallerIdentityFn + defer func() { + appLoadCallerIdentityFn = origLoad + }() + + loadCalled := false + appLoadCallerIdentityFn = func(Model) tea.Cmd { + loadCalled = true + return func() tea.Msg { + return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + } + } + + m := New(&config.Config{ + AuthType: config.AuthTypeCredential, + Profile: "default", + Region: "us-east-1", + }, "", "dev") + + msg := m.loadStartupCallerIdentity()() + if _, ok := msg.(callerIdentityMsg); !ok { + t.Fatalf("expected callerIdentityMsg, got %T", msg) + } + if !loadCalled { + t.Fatal("expected non-SSO startup identity loading to continue") + } +} + func TestListIndexHelpersWrapAndClamp(t *testing.T) { if got := previousListIndex(0, 3); got != 2 { t.Fatalf("expected previous from first to wrap to 2, got %d", got) From 2cc65877e6c1e87799f256cf86da70fdd2082dda Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Wed, 27 May 2026 11:53:33 +0900 Subject: [PATCH 2/2] test: cover startup SSO check failure - assert failed SSO session checks skip startup identity loading - cover the same passive behavior as missing SSO cache --- internal/app/app_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index ba74200..992db36 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -193,6 +193,45 @@ func TestStartupCallerIdentitySkipsInteractiveSSOLoginWhenCacheMissing(t *testin } } +func TestStartupCallerIdentitySkipsInteractiveSSOLoginWhenSessionCheckFails(t *testing.T) { + origCheck := contextCheckSSOSessionFn + origLoad := appLoadCallerIdentityFn + defer func() { + contextCheckSSOSessionFn = origCheck + appLoadCallerIdentityFn = origLoad + }() + + checkCalled := false + loadCalled := false + contextCheckSSOSessionFn = func(cfg *config.Config) (awsservice.SSOSessionCheck, error) { + checkCalled = true + return awsservice.SSOSessionCheck{}, os.ErrNotExist + } + appLoadCallerIdentityFn = func(Model) tea.Cmd { + loadCalled = true + return func() tea.Msg { + return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + } + } + + m := New(&config.Config{ + AuthType: config.AuthTypeSSO, + SSOStartURL: "https://example.awsapps.com/start", + Region: "us-east-1", + }, "", "dev") + + msg := m.loadStartupCallerIdentity()() + if _, ok := msg.(callerIdentityMsg); !ok { + t.Fatalf("expected callerIdentityMsg, got %T", msg) + } + if !checkCalled { + t.Fatal("expected startup to check SSO cache") + } + if loadCalled { + t.Fatal("expected startup to skip identity loading when SSO session check fails") + } +} + func TestStartupCallerIdentityLoadsSSOIdentityWhenCacheValid(t *testing.T) { origCheck := contextCheckSSOSessionFn origLoad := appLoadCallerIdentityFn