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..992db36 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -151,6 +151,149 @@ 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 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 + 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)