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()) +}