From 8bfaa175375dcf3e048b7c074d3c0aeb5b6da9e4 Mon Sep 17 00:00:00 2001 From: Christina Chu Date: Tue, 20 Oct 2026 11:35:50 -0700 Subject: [PATCH] feat: improve AWS credential errors The credential error is short. If the SSO token of the profile is expired or not in the cache, the error gives the aws sso login advice. SSOTokenInvalid finds this for sso_session profiles, which aws configure sso writes, and for legacy sso_start_url profiles. Tests use the real SDK with real config files. Any other cause shows in the error, also for an SSO profile, except the cause from the EC2 metadata service. That cause only says that no other source had credentials. The error lists at most 5 profile names. errors.Unwrap still returns the cause. Without --profile, the profile comes from AWS_PROFILE, then AWS_DEFAULT_PROFILE, then the default profile, as in the SDK. sso.go adds SelectedProfile, SSOProfile, HasSSOConfig, and SSOTokenInvalid. Co-Authored-By: Claude Opus 5.5 --- internal/awsdevops/awsdevops.go | 93 +++++--- internal/awsdevops/awsdevops_test.go | 10 +- internal/awsdevops/credentials_test.go | 285 +++++++++++++++++++++++ internal/awsdevops/regions_token_test.go | 1 + internal/awsdevops/sso.go | 124 ++++++++++ internal/awsdevops/sso_test.go | 98 ++++++++ 6 files changed, 572 insertions(+), 39 deletions(-) create mode 100644 internal/awsdevops/credentials_test.go create mode 100644 internal/awsdevops/sso.go create mode 100644 internal/awsdevops/sso_test.go diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go index 5addb6bc9..45106c82f 100644 --- a/internal/awsdevops/awsdevops.go +++ b/internal/awsdevops/awsdevops.go @@ -144,58 +144,79 @@ func (c Clients) AccountID(ctx context.Context) (string, error) { return *out.Account, nil } -// noCredsErr wraps a formatted message while still satisfying -// errors.Is(err, ErrNoCredentials). Callers get an actionable message -// without losing the ability to branch on the sentinel. +// noCredsErr hides its cause from Error. type noCredsErr struct { msg string cause error } -func (e *noCredsErr) Error() string { - if e.cause == nil { - return e.msg - } - - return fmt.Sprintf("%s: %s", e.msg, e.cause) -} - +func (e *noCredsErr) Error() string { return e.msg } func (e *noCredsErr) Unwrap() error { return e.cause } func (e *noCredsErr) Is(target error) bool { return target == ErrNoCredentials } +// maxListedProfiles caps the profile names in an error. +const maxListedProfiles = 5 + // noCredentialsError builds the surfaced message for a credential-resolution // failure. profile is the value passed via --profile; cause is the underlying // SDK error, which may be nil when credentials simply had no keys. func noCredentialsError(profile string, cause error) error { - active := profile - if active == "" { - active = os.Getenv("AWS_PROFILE") - } + selected, chosen := SelectedProfile(profile) + profiles := knownProfiles() + // The default profile counts only if it exists. + named := chosen || slices.Contains(profiles, selected) + // Only a cause that is not from IMDS tells what failed, for example a + // credential_process. + useful := cause != nil && !fromIMDS(cause) + sso := SSOProfile(ConfigFilePath(), profile) + var msg string switch { - case active != "": - return &noCredsErr{ - msg: fmt.Sprintf( - "no AWS credentials found for profile %q. Run `aws sso login --profile %s` (or refresh the profile's credentials), then re-run this command", - active, active, - ), - cause: cause, - } - case len(knownProfiles()) > 0: - profiles := knownProfiles() - return &noCredsErr{ - msg: fmt.Sprintf( - "no AWS credentials found and no AWS profile selected. Pass --profile or set AWS_PROFILE, then re-run. Profiles configured on this machine: %s", - strings.Join(profiles, ", "), - ), - cause: cause, - } + case sso != "" && (cause == nil || SSOTokenInvalid(cause)): + msg = fmt.Sprintf( + "no AWS credentials for profile %q. Run `aws sso login --profile %s`, then run this command again", + sso, sso, + ) + case useful && named: + msg = fmt.Sprintf("unable to get AWS credentials for profile %q: %v", selected, cause) + case useful: + msg = fmt.Sprintf("unable to get AWS credentials: %v", cause) + case chosen: + msg = fmt.Sprintf( + "profile %q has no AWS credentials. Add credentials to it, or pass --profile with another profile", + selected, + ) + case len(profiles) > 0: + msg = fmt.Sprintf( + "no AWS profile selected. Pass --profile or set AWS_PROFILE. Profiles: %s. Run `aws configure list-profiles` to see all", + summarizeProfiles(profiles), + ) default: - return &noCredsErr{ - msg: "no AWS credentials found. Authenticate first (for example `aws sso login --profile my-profile` or exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN), then re-run this command", - cause: cause, - } + msg = "no AWS credentials found. Run `aws sso login --profile ` or set AWS_ACCESS_KEY_ID" + } + + return &noCredsErr{msg: msg, cause: cause} +} + +// fromIMDS reports a cause from the EC2 metadata service. The SDK tries it +// last, so its error only says that no other source had credentials. +func fromIMDS(err error) bool { + var op interface{ Service() string } + + return errors.As(err, &op) && op.Service() == "ec2imds" +} + +// summarizeProfiles lists the first few names. +func summarizeProfiles(profiles []string) string { + if len(profiles) <= maxListedProfiles { + return strings.Join(profiles, ", ") } + + return fmt.Sprintf( + "%s and %d more", + strings.Join(profiles[:maxListedProfiles], ", "), + len(profiles)-maxListedProfiles, + ) } // knownProfiles returns the profile names configured in the shared AWS diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index 70b099366..65c302720 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -677,6 +677,7 @@ func TestNewClientsRequiresCredentials(t *testing.T) { t.Setenv("AWS_SECRET_ACCESS_KEY", "") t.Setenv("AWS_SESSION_TOKEN", "") t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") t.Setenv("AWS_CONFIG_FILE", "/dev/null") t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") @@ -684,7 +685,7 @@ func TestNewClientsRequiresCredentials(t *testing.T) { _, err := awsdevops.NewClients(context.Background(), "us-east-1", "") assert.ErrorIs(t, err, awsdevops.ErrNoCredentials) - assert.ErrorContains(t, err, "Authenticate first") + assert.ErrorContains(t, err, "aws sso login") } func TestNewClientsSuggestsSelectingAProfile(t *testing.T) { @@ -698,6 +699,7 @@ func TestNewClientsSuggestsSelectingAProfile(t *testing.T) { t.Setenv("AWS_SECRET_ACCESS_KEY", "") t.Setenv("AWS_SESSION_TOKEN", "") t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") t.Setenv("AWS_CONFIG_FILE", configPath) t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") @@ -725,6 +727,7 @@ func TestNewClientsNamesTheSelectedProfile(t *testing.T) { t.Setenv("AWS_SECRET_ACCESS_KEY", "") t.Setenv("AWS_SESSION_TOKEN", "") t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") t.Setenv("AWS_CONFIG_FILE", configPath) t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") @@ -732,6 +735,7 @@ func TestNewClientsNamesTheSelectedProfile(t *testing.T) { _, err := awsdevops.NewClients(context.Background(), "us-east-1", "my-profile") assert.ErrorIs(t, err, awsdevops.ErrNoCredentials) - assert.ErrorContains(t, err, `profile "my-profile"`) - assert.ErrorContains(t, err, "aws sso login --profile my-profile") + assert.ErrorContains(t, err, `profile "my-profile" has no AWS credentials`) + assert.NotContains(t, err.Error(), "sso login") + assert.NotContains(t, err.Error(), "ec2imds") } diff --git a/internal/awsdevops/credentials_test.go b/internal/awsdevops/credentials_test.go new file mode 100644 index 000000000..0379ebd62 --- /dev/null +++ b/internal/awsdevops/credentials_test.go @@ -0,0 +1,285 @@ +package awsdevops + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const imdsCause = "operation error ec2imds: GetMetadata, exceeded maximum number of attempts, 3, " + + "request send failed, Get \"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"" + +// imdsErr acts like the SDK error from the EC2 metadata service. +type imdsErr struct{} + +func (imdsErr) Error() string { return imdsCause } +func (imdsErr) Service() string { return "ec2imds" } + +func imdsFailure() error { + return fmt.Errorf("failed to refresh cached credentials, no EC2 IMDS role found, %w", imdsErr{}) +} + +// writeConfig points the SDK at a temp config with this content. +func writeConfig(t *testing.T, content string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + t.Setenv("AWS_CONFIG_FILE", path) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") +} + +// writeProfiles points the SDK at a temp config. +func writeProfiles(t *testing.T, names ...string) { + t.Helper() + var b strings.Builder + for _, name := range names { + fmt.Fprintf(&b, "[profile %s]\nregion = us-east-1\n\n", name) + } + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(path, []byte(b.String()), 0o600)) + t.Setenv("AWS_CONFIG_FILE", path) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") +} + +func TestCredentialErrorHidesTheSDKCause(t *testing.T) { + cases := map[string]struct { + profiles []string + selected string + }{ + "named profile": {profiles: []string{"dev"}, selected: "dev"}, + "profiles but none selected": {profiles: []string{"dev", "prod"}}, + "no profiles": {}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + writeProfiles(t, tc.profiles...) + cause := imdsFailure() + + err := noCredentialsError(tc.selected, cause) + + assert.NotContains(t, err.Error(), "169.254.169.254") + assert.NotContains(t, err.Error(), "attempts") + assert.ErrorIs(t, err, ErrNoCredentials) + assert.Same(t, cause, errors.Unwrap(err)) + }) + } +} + +// expiredSSO acts like the SDK error for an expired SSO token. +func expiredSSO() error { + return fmt.Errorf("failed to refresh cached credentials, %w", &ssocreds.InvalidTokenError{}) +} + +func TestCredentialErrorNamesTheProfileAndLogin(t *testing.T) { + writeConfig(t, "[profile dev]\nsso_session = corp\n") + + err := noCredentialsError("dev", expiredSSO()) + + assert.Contains(t, err.Error(), `"dev"`) + assert.Contains(t, err.Error(), "aws sso login --profile dev") +} + +func TestCredentialErrorNamesTheDefaultSSOProfile(t *testing.T) { + writeConfig(t, "[default]\nsso_session = corp\n") + + err := noCredentialsError("", expiredSSO()) + + assert.Contains(t, err.Error(), "aws sso login --profile default") +} + +func TestCredentialErrorReadsAWSDefaultProfile(t *testing.T) { + writeConfig(t, "[profile corp]\nsso_session = corp\n") + t.Setenv("AWS_DEFAULT_PROFILE", "corp") + + err := noCredentialsError("", expiredSSO()) + + assert.Contains(t, err.Error(), "aws sso login --profile corp") +} + +// A login does not fix an SSO error with another cause. +func TestCredentialErrorShowsOtherSSOCauses(t *testing.T) { + writeConfig(t, "[profile dev]\nsso_session = corp\n") + cause := errors.New("ForbiddenException: No access") + + err := noCredentialsError("dev", cause) + + assert.Contains(t, err.Error(), `profile "dev"`) + assert.Contains(t, err.Error(), "No access") + assert.NotContains(t, err.Error(), "sso login") +} + +// The SDK uses the default profile without --profile or AWS_PROFILE. +func TestCredentialErrorShowsTheCauseForTheDefaultProfile(t *testing.T) { + writeConfig(t, "[default]\ncredential_process = /usr/local/bin/get-creds\n") + cause := errors.New("process provider error: exit status 1") + + err := noCredentialsError("", cause) + + assert.Contains(t, err.Error(), `profile "default"`) + assert.Contains(t, err.Error(), "exit status 1") + assert.NotContains(t, err.Error(), "no AWS profile selected") +} + +// Only SSO profiles get the SSO advice. +func TestCredentialErrorShowsTheCauseForOtherProfiles(t *testing.T) { + writeConfig(t, "[profile ci]\ncredential_process = /usr/local/bin/get-creds\n") + cause := errors.New("process provider error: exit status 1") + + err := noCredentialsError("ci", cause) + + assert.Contains(t, err.Error(), `profile "ci"`) + assert.Contains(t, err.Error(), "exit status 1") + assert.NotContains(t, err.Error(), "sso login") + assert.ErrorIs(t, err, ErrNoCredentials) +} + +func TestCredentialErrorSaysAProfileHasNoCredentials(t *testing.T) { + writeProfiles(t, "dev") + + for name, cause := range map[string]error{"imds": imdsFailure(), "no keys": nil} { + t.Run(name, func(t *testing.T) { + err := noCredentialsError("dev", cause) + + assert.Contains(t, err.Error(), `profile "dev" has no AWS credentials`) + assert.NotContains(t, err.Error(), "sso login") + assert.NotContains(t, err.Error(), "169.254.169.254") + }) + } +} + +func TestCredentialErrorShowsFiveProfilesAtMost(t *testing.T) { + var names []string + for i := 0; i < 50; i++ { + names = append(names, fmt.Sprintf("profile-%02d", i)) + } + writeProfiles(t, names...) + + msg := noCredentialsError("", nil).Error() + + shown := 0 + for _, name := range names { + if strings.Contains(msg, name) { + shown++ + } + } + assert.Equal(t, 5, shown) + assert.Contains(t, msg, "and 45 more") + assert.Contains(t, msg, "aws configure list-profiles") +} + +func TestCredentialErrorWithExactlyFiveProfiles(t *testing.T) { + names := []string{"a1", "b2", "c3", "d4", "e5"} + writeProfiles(t, names...) + + msg := noCredentialsError("", nil).Error() + + for _, name := range names { + assert.Contains(t, msg, name) + } + assert.NotContains(t, msg, "more") +} + +func TestCredentialErrorCountsPrefixProfilesSeparately(t *testing.T) { + // dev is a prefix of dev-admin. + writeProfiles(t, "a1", "b2", "c3", "dev-admin", "e5", "dev") + + msg := noCredentialsError("", nil).Error() + + assert.Contains(t, msg, "dev-admin") + assert.Contains(t, msg, "and 1 more") + assert.NotContains(t, msg, "dev,") + assert.False(t, strings.HasSuffix(strings.SplitN(msg, "and 1 more", 2)[0], "dev ")) +} + +func TestCredentialErrorCountsDuplicateProfilesOnce(t *testing.T) { + writeProfiles(t, "a1", "b2", "c3", "d4", "e5", "f6") + creds := filepath.Join(t.TempDir(), "credentials") + require.NoError(t, os.WriteFile(creds, []byte("[a1]\n[f6]\n"), 0o600)) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", creds) + + msg := noCredentialsError("", nil).Error() + + assert.Contains(t, msg, "and 1 more") +} + +func TestCredentialErrorHasNoSemicolonOrDash(t *testing.T) { + writeProfiles(t, "a1", "b2", "c3", "d4", "e5", "f6") + for _, profile := range []string{"", "a1"} { + msg := noCredentialsError(profile, imdsFailure()).Error() + assert.NotContains(t, msg, ";") + assert.NotContains(t, msg, "—") + } +} + +// writeSSOConfig writes an sso_session profile, the format that aws configure +// sso writes, and a legacy sso_start_url profile. HOME holds the token cache. +func writeSSOConfig(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + writeConfig(t, `[profile dev] +sso_session = corp +sso_account_id = 123456789012 +sso_role_name = Admin +region = us-east-1 + +[sso-session corp] +sso_start_url = https://example.awsapps.com/start +sso_region = us-east-1 + +[profile legacy] +sso_start_url = https://example.awsapps.com/start +sso_region = us-east-1 +sso_account_id = 123456789012 +sso_role_name = Admin +region = us-east-1 +`) +} + +// These tests use the real SDK, so they find the errors it really returns. +func TestSSOTokenInvalidForEachSDKCase(t *testing.T) { + cases := map[string]struct { + profile string + cache func(t *testing.T) + }{ + "sso_session without a cached token": {profile: "dev"}, + "sso_session with an expired token": {profile: "dev", cache: func(t *testing.T) { + path, err := ssocreds.StandardCachedTokenFilepath("corp") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(`{"accessToken":"old","expiresAt":"2020-01-01T00:00:00Z"}`), 0o600)) + }}, + "legacy sso_start_url without a cached token": {profile: "legacy"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + writeSSOConfig(t) + if tc.cache != nil { + tc.cache(t) + } + + _, err := NewClients(context.Background(), "us-east-1", tc.profile) + + require.ErrorIs(t, err, ErrNoCredentials) + assert.True(t, SSOTokenInvalid(err), "cause: %v", errors.Unwrap(err)) + assert.Contains(t, err.Error(), "aws sso login --profile "+tc.profile) + }) + } +} + +func TestSSOTokenInvalidIgnoresOtherErrors(t *testing.T) { + assert.False(t, SSOTokenInvalid(nil)) + assert.False(t, SSOTokenInvalid(errors.New("ForbiddenException: No access"))) +} diff --git a/internal/awsdevops/regions_token_test.go b/internal/awsdevops/regions_token_test.go index fcf192d4b..a01546c40 100644 --- a/internal/awsdevops/regions_token_test.go +++ b/internal/awsdevops/regions_token_test.go @@ -17,6 +17,7 @@ func disableAWSCredentials(t *testing.T) { t.Setenv("AWS_SECRET_ACCESS_KEY", "") t.Setenv("AWS_SESSION_TOKEN", "") t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") t.Setenv("AWS_CONFIG_FILE", "/dev/null") t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") diff --git a/internal/awsdevops/sso.go b/internal/awsdevops/sso.go new file mode 100644 index 000000000..2d3229ecc --- /dev/null +++ b/internal/awsdevops/sso.go @@ -0,0 +1,124 @@ +package awsdevops + +import ( + "bufio" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" +) + +// ConfigFilePath returns the shared AWS config file path. +func ConfigFilePath() string { + if v := os.Getenv("AWS_CONFIG_FILE"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, ".aws", "config") +} + +// SelectedProfile returns the profile that the SDK uses. Without --profile, the +// SDK reads AWS_PROFILE, then AWS_DEFAULT_PROFILE, then uses the default +// profile. chosen is false if the default profile is only the fallback. +func SelectedProfile(profile string) (name string, chosen bool) { + for _, name := range []string{profile, os.Getenv("AWS_PROFILE"), os.Getenv("AWS_DEFAULT_PROFILE")} { + if name != "" { + return name, true + } + } + + return "default", false +} + +// SSOProfile returns the profile that the SDK uses, if that profile uses SSO. +func SSOProfile(path, profile string) string { + name, _ := SelectedProfile(profile) + if !HasSSOConfig(path, name) { + return "" + } + + return name +} + +// SSOTokenInvalid reports an SSO token that is expired or not in the cache. +// Only then does aws sso login help. +func SSOTokenInvalid(err error) bool { + var invalid *ssocreds.InvalidTokenError + switch { + case err == nil: + return false + case errors.As(err, &invalid): + // A legacy profile with sso_start_url. + return true + case errors.Is(err, fs.ErrNotExist): + // An sso_session profile with no cached token. + return true + } + + // An sso_session token that expired and cannot be refreshed. The SDK has + // no error type for this case. A wrapper can hide the cause from Error, so + // look at each error in the chain. + for ; err != nil; err = errors.Unwrap(err) { + if strings.Contains(err.Error(), "refresh cached SSO token failed") { + return true + } + } + + return false +} + +// HasSSOConfig reports whether the profile uses SSO. +func HasSSOConfig(path, profile string) bool { + file, err := os.Open(path) + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + inProfile := false + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + inProfile = sectionProfile(line[1:len(line)-1]) == profile + continue + } + if !inProfile { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(value) == "" { + continue + } + switch strings.TrimSpace(key) { + case "sso_session", "sso_start_url": + return true + } + } + + return false +} + +// sectionProfile returns the profile a section names. +func sectionProfile(section string) string { + section = strings.TrimSpace(section) + if section == "default" { + return "default" + } + name, ok := strings.CutPrefix(section, "profile ") + if !ok { + return "" + } + + return strings.TrimSpace(name) +} diff --git a/internal/awsdevops/sso_test.go b/internal/awsdevops/sso_test.go new file mode 100644 index 000000000..9663a6d8d --- /dev/null +++ b/internal/awsdevops/sso_test.go @@ -0,0 +1,98 @@ +package awsdevops_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path +} + +func TestHasSSOConfigFindsSSOSession(t *testing.T) { + path := writeConfig(t, "[profile dev]\nsso_session = corp\nregion = us-east-1\n") + + assert.True(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigFindsLegacyStartURL(t *testing.T) { + path := writeConfig(t, "[profile dev]\nsso_start_url=https://corp.awsapps.com/start\n") + + assert.True(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigReadsDefaultSection(t *testing.T) { + path := writeConfig(t, "[default]\nsso_session = corp\n") + + assert.True(t, awsdevops.HasSSOConfig(path, "default")) +} + +func TestHasSSOConfigIgnoresNearMissProfile(t *testing.T) { + path := writeConfig(t, "[profile dev]\nregion = us-east-1\n\n[profile dev-admin]\nsso_session = corp\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigIgnoresOtherProfileBeforeIt(t *testing.T) { + path := writeConfig(t, "[profile dev-admin]\nsso_session = corp\n\n[profile dev]\nregion = us-east-1\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigIgnoresSSORegionOnly(t *testing.T) { + path := writeConfig(t, "[profile dev]\nsso_region = us-east-1\nsso_account_id = 123\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigIgnoresCommentedLines(t *testing.T) { + path := writeConfig(t, "[profile dev]\n# sso_start_url = https://corp.awsapps.com/start\n; sso_session = corp\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigIgnoresNearMissKey(t *testing.T) { + path := writeConfig(t, "[profile dev]\nsso_session_name = corp\nmy_sso_start_url = x\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigIgnoresSessionSection(t *testing.T) { + path := writeConfig(t, "[sso-session dev]\nsso_start_url = https://corp.awsapps.com/start\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigIgnoresBareDefaultForOtherName(t *testing.T) { + path := writeConfig(t, "[default]\nsso_session = corp\n") + + assert.False(t, awsdevops.HasSSOConfig(path, "dev")) +} + +func TestHasSSOConfigHandlesMissingFile(t *testing.T) { + assert.False(t, awsdevops.HasSSOConfig(filepath.Join(t.TempDir(), "absent"), "dev")) +} + +func TestConfigFilePathUsesEnvOverride(t *testing.T) { + t.Setenv("AWS_CONFIG_FILE", "/elsewhere/aws-config") + + assert.Equal(t, "/elsewhere/aws-config", awsdevops.ConfigFilePath()) +} + +func TestConfigFilePathDefaultsToHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("AWS_CONFIG_FILE", "") + + assert.Equal(t, filepath.Join(home, ".aws", "config"), awsdevops.ConfigFilePath()) +}