diff --git a/lib/cache/cache_test.go b/lib/cache/cache_test.go new file mode 100644 index 0000000..ec609c7 --- /dev/null +++ b/lib/cache/cache_test.go @@ -0,0 +1,248 @@ +package cache + +import ( + "testing" + + "github.com/kionsoftware/kion-cli/lib/kion" +) + +func TestNewNullCache(t *testing.T) { + cache := NewNullCache() + if cache == nil { + t.Error("NewNullCache() returned nil") + } +} + +func TestNullCacheImplementsInterface(t *testing.T) { + // Verify NullCache satisfies the Cache interface at compile time. + // This is a compile-time check - if NullCache doesn't implement Cache, + // this file won't compile. + var _ Cache = (*NullCache)(nil) +} + +func TestNullCache_Stak(t *testing.T) { + tests := []struct { + name string + carName string + accNum string + accAlias string + stak kion.STAK + }{ + { + name: "typical values", + carName: "AdminRole", + accNum: "123456789012", + accAlias: "my-account", + stak: kion.STAK{ + AccessKey: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "token123", + }, + }, + { + name: "empty strings", + carName: "", + accNum: "", + accAlias: "", + stak: kion.STAK{}, + }, + { + name: "only car name", + carName: "ReadOnly", + accNum: "", + accAlias: "", + stak: kion.STAK{AccessKey: "AKIA123"}, + }, + { + name: "special characters", + carName: "role/with/slashes", + accNum: "000000000000", + accAlias: "alias-with-dashes_and_underscores", + stak: kion.STAK{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cache := NewNullCache() + + // SetStak should always succeed + err := cache.SetStak(test.carName, test.accNum, test.accAlias, test.stak) + if err != nil { + t.Errorf("SetStak() returned error: %v", err) + } + + // GetStak should always return empty, false, nil + stak, found, err := cache.GetStak(test.carName, test.accNum, test.accAlias) + if err != nil { + t.Errorf("GetStak() returned error: %v", err) + } + if found { + t.Error("GetStak() returned found=true, want false") + } + if stak != (kion.STAK{}) { + t.Errorf("GetStak() returned non-empty STAK: %+v", stak) + } + }) + } +} + +func TestNullCache_Session(t *testing.T) { + tests := []struct { + name string + session kion.Session + }{ + { + name: "typical session", + session: kion.Session{ + IDMSID: 1, + UserName: "user@example.com", + }, + }, + { + name: "empty session", + session: kion.Session{}, + }, + { + name: "zero IDMS ID", + session: kion.Session{ + IDMSID: 0, + UserName: "user@example.com", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cache := NewNullCache() + + // SetSession should always succeed + err := cache.SetSession(test.session) + if err != nil { + t.Errorf("SetSession() returned error: %v", err) + } + + // GetSession should always return empty, false, nil + session, found, err := cache.GetSession() + if err != nil { + t.Errorf("GetSession() returned error: %v", err) + } + if found { + t.Error("GetSession() returned found=true, want false") + } + if session != (kion.Session{}) { + t.Errorf("GetSession() returned non-empty Session: %+v", session) + } + }) + } +} + +func TestNullCache_Password(t *testing.T) { + tests := []struct { + name string + host string + idmsID uint + username string + password string + }{ + { + name: "typical values", + host: "https://kion.example.com", + idmsID: 1, + username: "user@example.com", + password: "secretpassword123", + }, + { + name: "empty strings", + host: "", + idmsID: 0, + username: "", + password: "", + }, + { + name: "zero IDMS ID", + host: "https://kion.example.com", + idmsID: 0, + username: "user@example.com", + password: "password", + }, + { + name: "special characters in password", + host: "https://kion.example.com", + idmsID: 1, + username: "user@example.com", + password: "p@$$w0rd!#$%^&*()", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cache := NewNullCache() + + // SetPassword should always succeed + err := cache.SetPassword(test.host, test.idmsID, test.username, test.password) + if err != nil { + t.Errorf("SetPassword() returned error: %v", err) + } + + // GetPassword should always return empty, false, nil + password, found, err := cache.GetPassword(test.host, test.idmsID, test.username) + if err != nil { + t.Errorf("GetPassword() returned error: %v", err) + } + if found { + t.Error("GetPassword() returned found=true, want false") + } + if password != "" { + t.Errorf("GetPassword() returned non-empty password: %q", password) + } + }) + } +} + +func TestNullCache_FlushCache(t *testing.T) { + cache := NewNullCache() + err := cache.FlushCache() + if err != nil { + t.Errorf("FlushCache() returned error: %v", err) + } + + // Calling multiple times should still succeed + err = cache.FlushCache() + if err != nil { + t.Errorf("FlushCache() second call returned error: %v", err) + } +} + +func TestNullCache_MultipleInstances(t *testing.T) { + // Verify that multiple NullCache instances don't interfere with each other + // (they shouldn't, since they don't store anything) + cache1 := NewNullCache() + cache2 := NewNullCache() + + // Set data on cache1 + _ = cache1.SetStak("role1", "111111111111", "alias1", kion.STAK{AccessKey: "KEY1"}) + _ = cache1.SetSession(kion.Session{IDMSID: 1, UserName: "user1"}) + _ = cache1.SetPassword("host1", 1, "user1", "pass1") + + // Set different data on cache2 + _ = cache2.SetStak("role2", "222222222222", "alias2", kion.STAK{AccessKey: "KEY2"}) + _ = cache2.SetSession(kion.Session{IDMSID: 2, UserName: "user2"}) + _ = cache2.SetPassword("host2", 2, "user2", "pass2") + + // Both should return empty for any query + stak1, found1, _ := cache1.GetStak("role1", "111111111111", "alias1") + stak2, found2, _ := cache2.GetStak("role2", "222222222222", "alias2") + + if found1 || found2 { + t.Error("NullCache should never return found=true") + } + if stak1 != (kion.STAK{}) || stak2 != (kion.STAK{}) { + t.Error("NullCache should always return empty STAK") + } +} + +func TestRealCacheImplementsInterface(t *testing.T) { + // Verify RealCache satisfies the Cache interface at compile time. + var _ Cache = (*RealCache)(nil) +} diff --git a/lib/commands/validators.go b/lib/commands/validators.go index 22996a4..66cc665 100644 --- a/lib/commands/validators.go +++ b/lib/commands/validators.go @@ -454,21 +454,31 @@ func (c *Cmd) ValidateCmdConsole(cCtx *cli.Context) error { // ValidateCmdRun validates the flags passed to the run command and sets the // favorites region as the default region if needed to ensure precedence. func (c *Cmd) ValidateCmdRun(cCtx *cli.Context) error { + favName := cCtx.String("favorite") + account := cCtx.String("account") + alias := cCtx.String("alias") + car := cCtx.String("car") + // Validate that either a favorite is used or both account/alias and car are provided - if cCtx.String("favorite") == "" && ((cCtx.String("account") == "" && cCtx.String("alias") == "") || cCtx.String("car") == "") { + hasAccountOrAlias := account != "" || alias != "" + hasCar := car != "" + hasFavorite := favName != "" + + if !hasFavorite && (!hasAccountOrAlias || !hasCar) { return errors.New("must specify either --fav OR --account and --car OR --alias and --car parameters") } + // If using account/alias + car directly, no favorite lookup needed + if !hasFavorite { + return nil + } + // Set the favorite region as the default region if a favorite is used - favName := cCtx.String("favorite") _, fMap := helper.MapFavs(c.config.Favorites) - var fav string - if fMap[favName] != (structs.Favorite{}) { - fav = favName - } else { + if fMap[favName] == (structs.Favorite{}) { return errors.New("can't find favorite") } - favorite := fMap[fav] + favorite := fMap[favName] if favorite.Region != "" { c.config.Kion.DefaultRegion = favorite.Region } diff --git a/lib/commands/validators_test.go b/lib/commands/validators_test.go new file mode 100644 index 0000000..3cbbd26 --- /dev/null +++ b/lib/commands/validators_test.go @@ -0,0 +1,435 @@ +package commands + +import ( + "flag" + "testing" + + "github.com/kionsoftware/kion-cli/lib/structs" + "github.com/urfave/cli/v2" +) + +// newTestContext creates a cli.Context for testing with the given flags. +func newTestContext(t *testing.T, flags map[string]string) *cli.Context { + t.Helper() + app := &cli.App{} + set := flag.NewFlagSet("test", flag.ContinueOnError) + + // Register all flags we might need + for name := range flags { + set.String(name, "", "") + } + + // Set flag values + for name, value := range flags { + if err := set.Set(name, value); err != nil { + t.Fatalf("failed to set flag %q to %q: %v", name, value, err) + } + } + + return cli.NewContext(app, set, nil) +} + +func TestValidateCmdStak(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantErr bool + errMsg string + }{ + { + name: "no flags - valid", + flags: map[string]string{}, + wantErr: false, + }, + { + name: "account and car - valid", + flags: map[string]string{ + "account": "123456789012", + "car": "AdminRole", + }, + wantErr: false, + }, + { + name: "alias and car - valid", + flags: map[string]string{ + "alias": "my-account", + "car": "AdminRole", + }, + wantErr: false, + }, + { + name: "account without car - invalid", + flags: map[string]string{ + "account": "123456789012", + }, + wantErr: true, + errMsg: "must specify --car parameter when using --account or --alias", + }, + { + name: "alias without car - invalid", + flags: map[string]string{ + "alias": "my-account", + }, + wantErr: true, + errMsg: "must specify --car parameter when using --account or --alias", + }, + { + name: "car without account or alias - invalid", + flags: map[string]string{ + "car": "AdminRole", + }, + wantErr: true, + errMsg: "must specify --account OR --alias parameter when using --car", + }, + { + name: "all three flags - valid", + flags: map[string]string{ + "account": "123456789012", + "alias": "my-account", + "car": "AdminRole", + }, + wantErr: false, + }, + } + + cmd := &Cmd{} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := newTestContext(t, test.flags) + err := cmd.ValidateCmdStak(ctx) + + if test.wantErr { + if err == nil { + t.Error("expected error but got nil") + } else if err.Error() != test.errMsg { + t.Errorf("error = %q, want %q", err.Error(), test.errMsg) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateCmdConsole(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantErr bool + errMsg string + }{ + { + name: "no flags - valid", + flags: map[string]string{}, + wantErr: false, + }, + { + name: "account and car - valid", + flags: map[string]string{ + "account": "123456789012", + "car": "AdminRole", + }, + wantErr: false, + }, + { + name: "alias and car - valid", + flags: map[string]string{ + "alias": "my-account", + "car": "AdminRole", + }, + wantErr: false, + }, + { + name: "car without account or alias - invalid", + flags: map[string]string{ + "car": "AdminRole", + }, + wantErr: true, + errMsg: "must specify --account or --alias parameter when using --car", + }, + { + name: "account without car - invalid", + flags: map[string]string{ + "account": "123456789012", + }, + wantErr: true, + errMsg: "must specify --car parameter when using --account or --alias", + }, + { + name: "alias without car - invalid", + flags: map[string]string{ + "alias": "my-account", + }, + wantErr: true, + errMsg: "must specify --car parameter when using --account or --alias", + }, + } + + cmd := &Cmd{} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := newTestContext(t, test.flags) + err := cmd.ValidateCmdConsole(ctx) + + if test.wantErr { + if err == nil { + t.Error("expected error but got nil") + } else if err.Error() != test.errMsg { + t.Errorf("error = %q, want %q", err.Error(), test.errMsg) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateCmdRun(t *testing.T) { + tests := []struct { + name string + flags map[string]string + favorites []structs.Favorite + wantErr bool + errMsg string + }{ + // Favorite-based tests + { + name: "valid favorite", + flags: map[string]string{ + "favorite": "my-fav", + }, + favorites: []structs.Favorite{ + {Name: "my-fav", Account: "123456789012", CAR: "AdminRole"}, + }, + wantErr: false, + }, + { + name: "favorite not found", + flags: map[string]string{ + "favorite": "nonexistent", + }, + favorites: []structs.Favorite{ + {Name: "my-fav", Account: "123456789012", CAR: "AdminRole"}, + }, + wantErr: true, + errMsg: "can't find favorite", + }, + { + name: "favorite not found - empty favorites list", + flags: map[string]string{ + "favorite": "my-fav", + }, + favorites: []structs.Favorite{}, + wantErr: true, + errMsg: "can't find favorite", + }, + + // Account/alias + car tests (without favorite) + { + name: "account and car without favorite - valid", + flags: map[string]string{ + "account": "123456789012", + "car": "AdminRole", + }, + favorites: []structs.Favorite{}, + wantErr: false, + }, + { + name: "alias and car without favorite - valid", + flags: map[string]string{ + "alias": "my-account", + "car": "AdminRole", + }, + favorites: []structs.Favorite{}, + wantErr: false, + }, + { + name: "account, alias and car without favorite - valid", + flags: map[string]string{ + "account": "123456789012", + "alias": "my-account", + "car": "AdminRole", + }, + favorites: []structs.Favorite{}, + wantErr: false, + }, + + // Invalid combinations + { + name: "no flags at all - invalid", + flags: map[string]string{}, + favorites: []structs.Favorite{}, + wantErr: true, + errMsg: "must specify either --fav OR --account and --car OR --alias and --car parameters", + }, + { + name: "account without car - invalid", + flags: map[string]string{ + "account": "123456789012", + }, + favorites: []structs.Favorite{}, + wantErr: true, + errMsg: "must specify either --fav OR --account and --car OR --alias and --car parameters", + }, + { + name: "alias without car - invalid", + flags: map[string]string{ + "alias": "my-account", + }, + favorites: []structs.Favorite{}, + wantErr: true, + errMsg: "must specify either --fav OR --account and --car OR --alias and --car parameters", + }, + { + name: "car without account or alias - invalid", + flags: map[string]string{ + "car": "AdminRole", + }, + favorites: []structs.Favorite{}, + wantErr: true, + errMsg: "must specify either --fav OR --account and --car OR --alias and --car parameters", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &Cmd{ + config: &structs.Configuration{ + Favorites: test.favorites, + }, + } + ctx := newTestContext(t, test.flags) + err := cmd.ValidateCmdRun(ctx) + + if test.wantErr { + if err == nil { + t.Error("expected error but got nil") + } else if err.Error() != test.errMsg { + t.Errorf("error = %q, want %q", err.Error(), test.errMsg) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateCmdRun_SetsDefaultRegion(t *testing.T) { + t.Run("sets region from favorite", func(t *testing.T) { + cmd := &Cmd{ + config: &structs.Configuration{ + Favorites: []structs.Favorite{ + {Name: "my-fav", Account: "123456789012", CAR: "AdminRole", Region: "us-west-2"}, + }, + Kion: structs.Kion{ + DefaultRegion: "", + }, + }, + } + + ctx := newTestContext(t, map[string]string{"favorite": "my-fav"}) + err := cmd.ValidateCmdRun(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd.config.Kion.DefaultRegion != "us-west-2" { + t.Errorf("DefaultRegion = %q, want %q", cmd.config.Kion.DefaultRegion, "us-west-2") + } + }) + + t.Run("does not change region when favorite has no region", func(t *testing.T) { + cmd := &Cmd{ + config: &structs.Configuration{ + Favorites: []structs.Favorite{ + {Name: "my-fav", Account: "123456789012", CAR: "AdminRole", Region: ""}, + }, + Kion: structs.Kion{ + DefaultRegion: "us-east-1", + }, + }, + } + + ctx := newTestContext(t, map[string]string{"favorite": "my-fav"}) + err := cmd.ValidateCmdRun(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd.config.Kion.DefaultRegion != "us-east-1" { + t.Errorf("DefaultRegion = %q, want %q (should be unchanged)", cmd.config.Kion.DefaultRegion, "us-east-1") + } + }) + + t.Run("does not set region when using account+car", func(t *testing.T) { + cmd := &Cmd{ + config: &structs.Configuration{ + Favorites: []structs.Favorite{}, + Kion: structs.Kion{ + DefaultRegion: "us-east-1", + }, + }, + } + + ctx := newTestContext(t, map[string]string{ + "account": "123456789012", + "car": "AdminRole", + }) + err := cmd.ValidateCmdRun(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd.config.Kion.DefaultRegion != "us-east-1" { + t.Errorf("DefaultRegion = %q, want %q (should be unchanged)", cmd.config.Kion.DefaultRegion, "us-east-1") + } + }) +} + +func TestValidateCmdRun_NilFavorites(t *testing.T) { + t.Run("nil favorites with account+car succeeds", func(t *testing.T) { + cmd := &Cmd{ + config: &structs.Configuration{ + Favorites: nil, + }, + } + + ctx := newTestContext(t, map[string]string{ + "account": "123456789012", + "car": "AdminRole", + }) + err := cmd.ValidateCmdRun(ctx) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("nil favorites with favorite flag fails gracefully", func(t *testing.T) { + cmd := &Cmd{ + config: &structs.Configuration{ + Favorites: nil, + }, + } + + ctx := newTestContext(t, map[string]string{ + "favorite": "my-fav", + }) + err := cmd.ValidateCmdRun(ctx) + + if err == nil { + t.Error("expected error but got nil") + } else if err.Error() != "can't find favorite" { + t.Errorf("error = %q, want %q", err.Error(), "can't find favorite") + } + }) +} diff --git a/lib/helper/output_test.go b/lib/helper/output_test.go index bc47aa7..97115d8 100644 --- a/lib/helper/output_test.go +++ b/lib/helper/output_test.go @@ -84,6 +84,147 @@ func TestPrintSTAK(t *testing.T) { } } +func TestPrintFavoriteConfig(t *testing.T) { + tests := []struct { + name string + car kion.CAR + region string + accessType string + wantParts []string // Parts that must appear in output + }{ + { + name: "with region", + car: kion.CAR{ + AccountNumber: "123456789012", + Name: "AdminRole", + }, + region: "us-east-1", + accessType: "cli", + wantParts: []string{ + "account: 123456789012", + "cloud_access_role: AdminRole", + "region: us-east-1", + "access_type: cli", + "[your favorite alias]", + }, + }, + { + name: "without region", + car: kion.CAR{ + AccountNumber: "987654321098", + Name: "ReadOnlyRole", + }, + region: "", + accessType: "web", + wantParts: []string{ + "account: 987654321098", + "cloud_access_role: ReadOnlyRole", + "access_type: web", + }, + }, + { + name: "empty car values", + car: kion.CAR{ + AccountNumber: "", + Name: "", + }, + region: "", + accessType: "", + wantParts: []string{ + "account:", + "cloud_access_role:", + "access_type:", + }, + }, + { + name: "gov cloud region", + car: kion.CAR{ + AccountNumber: "111122223333", + Name: "GovRole", + }, + region: "us-gov-west-1", + accessType: "cli", + wantParts: []string{ + "account: 111122223333", + "cloud_access_role: GovRole", + "region: us-gov-west-1", + "access_type: cli", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var buf bytes.Buffer + err := PrintFavoriteConfig(&buf, test.car, test.region, test.accessType) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + for _, part := range test.wantParts { + if !containsString(output, part) { + t.Errorf("output missing expected part %q\noutput: %s", part, output) + } + } + + // If region is empty, verify "region:" line is not present + if test.region == "" && containsString(output, "region:") { + t.Errorf("output should not contain 'region:' when region is empty\noutput: %s", output) + } + }) + } +} + +func TestPrintFavoriteConfig_AlwaysReturnsNil(t *testing.T) { + var buf bytes.Buffer + err := PrintFavoriteConfig(&buf, kion.CAR{}, "", "") + if err != nil { + t.Errorf("expected nil error, got %v", err) + } +} + +// containsString checks if s contains substr, stripping ANSI codes first +func containsString(s, substr string) bool { + // Strip ANSI escape codes for comparison + stripped := stripANSI(s) + return contains(stripped, substr) +} + +func stripANSI(s string) string { + var result []byte + inEscape := false + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + inEscape = true + continue + } + if inEscape { + if s[i] == 'm' { + inEscape = false + } + continue + } + result = append(result, s[i]) + } + return string(result) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + func TestPrintCredentialProcess(t *testing.T) { tests := []struct { description string diff --git a/lib/helper/transform_test.go b/lib/helper/transform_test.go index e2a1db7..392cea5 100644 --- a/lib/helper/transform_test.go +++ b/lib/helper/transform_test.go @@ -246,3 +246,555 @@ func TestFindCARByName(t *testing.T) { }) } } + +func TestCombineFavorites_EmptyInputs(t *testing.T) { + all, comparison, err := CombineFavorites(nil, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 0 { + t.Errorf("expected empty All, got %d items", len(all)) + } + if len(comparison.LocalOnly) != 0 { + t.Errorf("expected empty LocalOnly, got %d items", len(comparison.LocalOnly)) + } + if len(comparison.ConflictsLocal) != 0 { + t.Errorf("expected empty ConflictsLocal, got %d items", len(comparison.ConflictsLocal)) + } +} + +func TestCombineFavorites_OnlyUpstream(t *testing.T) { + upstream := []structs.Favorite{ + {Name: "upstream1", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + {Name: "upstream2", Account: "222222222222", CAR: "Role2", AccessType: "web"}, + } + + all, comparison, err := CombineFavorites(nil, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 in All, got %d", len(all)) + } + if len(comparison.LocalOnly) != 0 { + t.Errorf("expected empty LocalOnly, got %d items", len(comparison.LocalOnly)) + } + for _, fav := range all { + if fav.DescriptiveName == "" { + t.Errorf("expected DescriptiveName to be set for %s", fav.Name) + } + } +} + +func TestCombineFavorites_OnlyLocal(t *testing.T) { + local := []structs.Favorite{ + {Name: "local1", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + {Name: "local2", Account: "222222222222", CAR: "Role2", AccessType: "web"}, + } + + all, comparison, err := CombineFavorites(local, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 in All, got %d", len(all)) + } + if len(comparison.LocalOnly) != 2 { + t.Errorf("expected 2 in LocalOnly, got %d", len(comparison.LocalOnly)) + } + if comparison.LocalOnly[0].Name != "local1" || comparison.LocalOnly[1].Name != "local2" { + t.Errorf("LocalOnly contains wrong favorites") + } +} + +func TestCombineFavorites_ExactMatch(t *testing.T) { + local := []structs.Favorite{ + {Name: "shared", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "shared", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 1 { + t.Errorf("expected 1 in All (upstream only), got %d", len(all)) + } + if len(comparison.LocalOnly) != 0 { + t.Errorf("expected empty LocalOnly for exact match, got %d", len(comparison.LocalOnly)) + } + if len(comparison.ConflictsLocal) != 0 { + t.Errorf("expected empty ConflictsLocal for exact match, got %d", len(comparison.ConflictsLocal)) + } +} + +func TestCombineFavorites_NameConflict(t *testing.T) { + local := []structs.Favorite{ + {Name: "myfav", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "myfav", Account: "222222222222", CAR: "Role2", AccessType: "web", Unaliased: false}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 in All, got %d", len(all)) + } + if len(comparison.ConflictsLocal) != 1 { + t.Errorf("expected 1 in ConflictsLocal, got %d", len(comparison.ConflictsLocal)) + } + if len(comparison.ConflictsUpstream) != 1 { + t.Errorf("expected 1 in ConflictsUpstream, got %d", len(comparison.ConflictsUpstream)) + } + if comparison.ConflictsLocal[0].Name != "myfav" { + t.Errorf("expected conflict to be 'myfav', got %s", comparison.ConflictsLocal[0].Name) + } +} + +func TestCombineFavorites_Duplicate(t *testing.T) { + local := []structs.Favorite{ + {Name: "local-name", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "upstream-name", Account: "111111111111", CAR: "Role1", AccessType: "cli", Unaliased: false}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 in All, got %d", len(all)) + } + if len(comparison.ConflictsLocal) != 1 { + t.Errorf("expected 1 in ConflictsLocal, got %d", len(comparison.ConflictsLocal)) + } + if len(comparison.ConflictsUpstream) != 1 { + t.Errorf("expected 1 in ConflictsUpstream, got %d", len(comparison.ConflictsUpstream)) + } +} + +func TestCombineFavorites_UnaliasedMatch(t *testing.T) { + local := []structs.Favorite{ + {Name: "my-local-name", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "", Account: "111111111111", CAR: "Role1", AccessType: "cli", Unaliased: true}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 1 { + t.Errorf("expected 1 in All, got %d", len(all)) + } + if all[0].Name != "my-local-name" { + t.Errorf("expected local favorite in All, got %s", all[0].Name) + } + if len(comparison.UnaliasedLocal) != 1 { + t.Errorf("expected 1 in UnaliasedLocal, got %d", len(comparison.UnaliasedLocal)) + } + if len(comparison.UnaliasedUpstream) != 1 { + t.Errorf("expected 1 in UnaliasedUpstream, got %d", len(comparison.UnaliasedUpstream)) + } + if len(comparison.LocalOnly) != 0 { + t.Errorf("expected empty LocalOnly, got %d", len(comparison.LocalOnly)) + } +} + +func TestCombineFavorites_MultipleConflictsWithSameUpstream(t *testing.T) { + local := []structs.Favorite{ + {Name: "shared", Account: "111111111111", CAR: "RoleA", AccessType: "cli"}, + {Name: "shared", Account: "222222222222", CAR: "RoleB", AccessType: "web"}, + } + upstream := []structs.Favorite{ + {Name: "shared", Account: "333333333333", CAR: "RoleC", AccessType: "cli", Unaliased: false}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 3 { + t.Errorf("expected 3 in All, got %d", len(all)) + } + if len(comparison.ConflictsLocal) != 2 { + t.Errorf("expected 2 in ConflictsLocal, got %d", len(comparison.ConflictsLocal)) + } + if len(comparison.ConflictsUpstream) != 1 { + t.Errorf("expected 1 in ConflictsUpstream (deduped), got %d", len(comparison.ConflictsUpstream)) + } +} + +func TestCombineFavorites_MixedScenarios(t *testing.T) { + local := []structs.Favorite{ + {Name: "exact", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + {Name: "local-only", Account: "999999999999", CAR: "UniqueRole", AccessType: "web"}, + {Name: "conflict", Account: "333333333333", CAR: "RoleX", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "exact", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + {Name: "conflict", Account: "444444444444", CAR: "RoleY", AccessType: "web", Unaliased: false}, + {Name: "upstream-only", Account: "555555555555", CAR: "RoleZ", AccessType: "cli"}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 5 { + t.Errorf("expected 5 in All, got %d", len(all)) + } + if len(comparison.LocalOnly) != 1 { + t.Errorf("expected 1 in LocalOnly, got %d", len(comparison.LocalOnly)) + } + if comparison.LocalOnly[0].Name != "local-only" { + t.Errorf("expected 'local-only' in LocalOnly, got %s", comparison.LocalOnly[0].Name) + } + if len(comparison.ConflictsLocal) != 1 { + t.Errorf("expected 1 in ConflictsLocal, got %d", len(comparison.ConflictsLocal)) + } + if comparison.ConflictsLocal[0].Name != "conflict" { + t.Errorf("expected 'conflict' in ConflictsLocal, got %s", comparison.ConflictsLocal[0].Name) + } +} + +func TestCombineFavorites_NilVsEmptySlice(t *testing.T) { + all1, comp1, err1 := CombineFavorites(nil, nil) + all2, comp2, err2 := CombineFavorites([]structs.Favorite{}, []structs.Favorite{}) + + if err1 != nil || err2 != nil { + t.Fatalf("unexpected errors: %v, %v", err1, err2) + } + if len(all1) != len(all2) { + t.Errorf("nil vs empty slice produced different All lengths: %d vs %d", len(all1), len(all2)) + } + if len(comp1.LocalOnly) != len(comp2.LocalOnly) { + t.Errorf("nil vs empty slice produced different LocalOnly lengths") + } +} + +func TestCombineFavorites_UnaliasedUpstreamWithSameName(t *testing.T) { + // When upstream has Unaliased=true but all fields match (including name), + // it's still treated as an exact match (exact match check comes first) + local := []structs.Favorite{ + {Name: "samename", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "samename", Account: "111111111111", CAR: "Role1", AccessType: "cli", Unaliased: true}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should be exact match (not unaliased match) because all fields including name match + if len(comparison.ConflictsLocal) != 0 { + t.Errorf("expected no conflicts, got %d", len(comparison.ConflictsLocal)) + } + if len(comparison.UnaliasedLocal) != 0 { + t.Errorf("expected no unaliased (exact match takes priority), got %d", len(comparison.UnaliasedLocal)) + } + // Only upstream in All (local is exact match, not added) + if len(all) != 1 { + t.Errorf("expected 1 in All, got %d", len(all)) + } +} + +func TestCombineFavorites_UnaliasedUpstreamDifferentName(t *testing.T) { + // When upstream has Unaliased=true and different name but same account/CAR/AccessType, + // it should be treated as unaliased match (local provides the name) + local := []structs.Favorite{ + {Name: "my-local-name", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + {Name: "different-name", Account: "111111111111", CAR: "Role1", AccessType: "cli", Unaliased: true}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should be unaliased match + if len(comparison.UnaliasedLocal) != 1 { + t.Errorf("expected 1 in UnaliasedLocal, got %d", len(comparison.UnaliasedLocal)) + } + if len(comparison.UnaliasedUpstream) != 1 { + t.Errorf("expected 1 in UnaliasedUpstream, got %d", len(comparison.UnaliasedUpstream)) + } + // Local replaces upstream in All + if len(all) != 1 { + t.Errorf("expected 1 in All, got %d", len(all)) + } + if all[0].Name != "my-local-name" { + t.Errorf("expected local name in All, got %s", all[0].Name) + } +} + +func TestCombineFavorites_ExactMatchTakesPriority(t *testing.T) { + // If local matches first upstream exactly, it shouldn't conflict with second + local := []structs.Favorite{ + {Name: "myfav", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + } + upstream := []structs.Favorite{ + // First upstream is exact match + {Name: "myfav", Account: "111111111111", CAR: "Role1", AccessType: "cli"}, + // Second upstream has same name but different settings (would be conflict) + {Name: "myfav", Account: "222222222222", CAR: "Role2", AccessType: "web"}, + } + + all, comparison, err := CombineFavorites(local, upstream) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should be exact match with first upstream, no conflict + if len(comparison.ConflictsLocal) != 0 { + t.Errorf("expected no conflicts (exact match takes priority), got %d", len(comparison.ConflictsLocal)) + } + if len(comparison.LocalOnly) != 0 { + t.Errorf("expected no LocalOnly, got %d", len(comparison.LocalOnly)) + } + // All should have both upstreams (local not added due to exact match) + if len(all) != 2 { + t.Errorf("expected 2 in All, got %d", len(all)) + } +} + +func TestCombineFavorites_ErrorAlwaysNil(t *testing.T) { + // Verify function never returns an error (current implementation) + testCases := []struct { + local []structs.Favorite + upstream []structs.Favorite + }{ + {nil, nil}, + {[]structs.Favorite{}, []structs.Favorite{}}, + {[]structs.Favorite{{Name: "a"}}, nil}, + {nil, []structs.Favorite{{Name: "b"}}}, + } + + for _, tc := range testCases { + _, _, err := CombineFavorites(tc.local, tc.upstream) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + } +} + +func TestMapAccountsFromCARS_Empty(t *testing.T) { + names, aMap := MapAccountsFromCARS(nil, 0) + + if len(names) != 0 { + t.Errorf("expected empty names, got %d", len(names)) + } + if len(aMap) != 0 { + t.Errorf("expected empty map, got %d", len(aMap)) + } +} + +func TestMapAccountsFromCARS_EmptySlice(t *testing.T) { + names, aMap := MapAccountsFromCARS([]kion.CAR{}, 0) + + if len(names) != 0 { + t.Errorf("expected empty names, got %d", len(names)) + } + if len(aMap) != 0 { + t.Errorf("expected empty map, got %d", len(aMap)) + } +} + +func TestMapAccountsFromCARS_WithAliases(t *testing.T) { + cars := []kion.CAR{ + {AccountName: "account one", AccountAlias: "alias-one", AccountNumber: "111111111111", ProjectID: 100}, + {AccountName: "account two", AccountAlias: "alias-two", AccountNumber: "222222222222", ProjectID: 100}, + } + + names, aMap := MapAccountsFromCARS(cars, 0) + + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d", len(names)) + } + + expectedName1 := "account one [alias-one] (111111111111)" + expectedName2 := "account two [alias-two] (222222222222)" + + // Check map has correct entries + if aMap[expectedName1] != "111111111111" { + t.Errorf("expected account number 111111111111 for %q, got %q", expectedName1, aMap[expectedName1]) + } + if aMap[expectedName2] != "222222222222" { + t.Errorf("expected account number 222222222222 for %q, got %q", expectedName2, aMap[expectedName2]) + } +} + +func TestMapAccountsFromCARS_WithoutAliases(t *testing.T) { + cars := []kion.CAR{ + {AccountName: "account one", AccountAlias: "", AccountNumber: "111111111111", ProjectID: 100}, + {AccountName: "account two", AccountAlias: "", AccountNumber: "222222222222", ProjectID: 100}, + } + + names, aMap := MapAccountsFromCARS(cars, 0) + + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d", len(names)) + } + + expectedName1 := "account one (111111111111)" + expectedName2 := "account two (222222222222)" + + if aMap[expectedName1] != "111111111111" { + t.Errorf("expected account number 111111111111 for %q, got %q", expectedName1, aMap[expectedName1]) + } + if aMap[expectedName2] != "222222222222" { + t.Errorf("expected account number 222222222222 for %q, got %q", expectedName2, aMap[expectedName2]) + } + + // Verify aliases are not included in format + for _, name := range names { + if contains := len(name) > 0 && name[len(name)-1] == ']'; contains { + // Check for bracket pattern indicating alias + for i := len(name) - 2; i >= 0; i-- { + if name[i] == '[' { + t.Errorf("expected no alias brackets in name %q", name) + break + } + } + } + } +} + +func TestMapAccountsFromCARS_ProjectFiltering(t *testing.T) { + cars := []kion.CAR{ + {AccountName: "project100 account", AccountNumber: "111111111111", ProjectID: 100}, + {AccountName: "project200 account", AccountNumber: "222222222222", ProjectID: 200}, + {AccountName: "project100 account2", AccountNumber: "333333333333", ProjectID: 100}, + } + + // pid=0 returns all + names, aMap := MapAccountsFromCARS(cars, 0) + if len(names) != 3 { + t.Errorf("pid=0: expected 3 names, got %d", len(names)) + } + if len(aMap) != 3 { + t.Errorf("pid=0: expected 3 map entries, got %d", len(aMap)) + } + + // pid=100 returns only project 100 + names, aMap = MapAccountsFromCARS(cars, 100) + if len(names) != 2 { + t.Errorf("pid=100: expected 2 names, got %d", len(names)) + } + if len(aMap) != 2 { + t.Errorf("pid=100: expected 2 map entries, got %d", len(aMap)) + } + + // pid=200 returns only project 200 + names, aMap = MapAccountsFromCARS(cars, 200) + if len(names) != 1 { + t.Errorf("pid=200: expected 1 name, got %d", len(names)) + } + if aMap["project200 account (222222222222)"] != "222222222222" { + t.Errorf("pid=200: expected project200 account in map") + } + + // pid=999 returns empty (no matching project) + names, aMap = MapAccountsFromCARS(cars, 999) + if len(names) != 0 { + t.Errorf("pid=999: expected 0 names, got %d", len(names)) + } + if len(aMap) != 0 { + t.Errorf("pid=999: expected 0 map entries, got %d", len(aMap)) + } +} + +func TestMapAccountsFromCARS_Deduplication(t *testing.T) { + // Same account appears in multiple CARs (different roles for same account) + cars := []kion.CAR{ + {AccountName: "shared account", AccountAlias: "shared", AccountNumber: "111111111111", ProjectID: 100, Name: "AdminRole"}, + {AccountName: "shared account", AccountAlias: "shared", AccountNumber: "111111111111", ProjectID: 100, Name: "ReadOnlyRole"}, + {AccountName: "shared account", AccountAlias: "shared", AccountNumber: "111111111111", ProjectID: 100, Name: "DevRole"}, + } + + names, aMap := MapAccountsFromCARS(cars, 0) + + // Should only have 1 entry (deduplicated) + if len(names) != 1 { + t.Errorf("expected 1 deduplicated name, got %d", len(names)) + } + if len(aMap) != 1 { + t.Errorf("expected 1 deduplicated map entry, got %d", len(aMap)) + } + + expectedName := "shared account [shared] (111111111111)" + if names[0] != expectedName { + t.Errorf("expected %q, got %q", expectedName, names[0]) + } +} + +func TestMapAccountsFromCARS_Sorting(t *testing.T) { + cars := []kion.CAR{ + {AccountName: "zebra account", AccountNumber: "333333333333", ProjectID: 100}, + {AccountName: "alpha account", AccountNumber: "111111111111", ProjectID: 100}, + {AccountName: "beta account", AccountNumber: "222222222222", ProjectID: 100}, + } + + names, _ := MapAccountsFromCARS(cars, 0) + + if len(names) != 3 { + t.Fatalf("expected 3 names, got %d", len(names)) + } + + // Should be sorted alphabetically + expected := []string{ + "alpha account (111111111111)", + "beta account (222222222222)", + "zebra account (333333333333)", + } + + for i, name := range names { + if name != expected[i] { + t.Errorf("position %d: expected %q, got %q", i, expected[i], name) + } + } +} + +func TestMapAccountsFromCARS_MixedAliases(t *testing.T) { + // Some accounts have aliases, some don't + cars := []kion.CAR{ + {AccountName: "account with alias", AccountAlias: "my-alias", AccountNumber: "111111111111", ProjectID: 100}, + {AccountName: "account without alias", AccountAlias: "", AccountNumber: "222222222222", ProjectID: 100}, + } + + names, aMap := MapAccountsFromCARS(cars, 0) + + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d", len(names)) + } + + withAlias := "account with alias [my-alias] (111111111111)" + withoutAlias := "account without alias (222222222222)" + + if aMap[withAlias] != "111111111111" { + t.Errorf("expected account with alias in map") + } + if aMap[withoutAlias] != "222222222222" { + t.Errorf("expected account without alias in map") + } +} diff --git a/lib/kion/kion_test.go b/lib/kion/kion_test.go new file mode 100644 index 0000000..9d138b1 --- /dev/null +++ b/lib/kion/kion_test.go @@ -0,0 +1,71 @@ +package kion + +import "testing" + +func TestConvertAccessType(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + // API to CLI format + {"console_access to web", "console_access", "web"}, + {"short_term_key_access to cli", "short_term_key_access", "cli"}, + + // CLI to API format + {"web to console_access", "web", "console_access"}, + {"cli to short_term_key_access", "cli", "short_term_key_access"}, + + // Passthrough for unknown values + {"empty string passthrough", "", ""}, + {"unknown value passthrough", "unknown", "unknown"}, + {"random string passthrough", "something_else", "something_else"}, + + // Case sensitivity - function is case-sensitive, these should passthrough + {"uppercase WEB passthrough", "WEB", "WEB"}, + {"uppercase CLI passthrough", "CLI", "CLI"}, + {"mixed case Web passthrough", "Web", "Web"}, + {"uppercase CONSOLE_ACCESS passthrough", "CONSOLE_ACCESS", "CONSOLE_ACCESS"}, + + // Whitespace - function does not trim, these should passthrough + {"leading space passthrough", " web", " web"}, + {"trailing space passthrough", "web ", "web "}, + {"space padded passthrough", " cli ", " cli "}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := ConvertAccessType(test.input) + if got != test.want { + t.Errorf("ConvertAccessType(%q) = %q, want %q", test.input, got, test.want) + } + }) + } +} + +func TestConvertAccessType_Bidirectional(t *testing.T) { + // Test that conversions are properly bidirectional + pairs := []struct { + apiFormat string + cliFormat string + }{ + {"console_access", "web"}, + {"short_term_key_access", "cli"}, + } + + for _, pair := range pairs { + t.Run("api_to_cli_"+pair.apiFormat, func(t *testing.T) { + got := ConvertAccessType(pair.apiFormat) + if got != pair.cliFormat { + t.Errorf("ConvertAccessType(%q) = %q, want %q", pair.apiFormat, got, pair.cliFormat) + } + }) + + t.Run("cli_to_api_"+pair.cliFormat, func(t *testing.T) { + got := ConvertAccessType(pair.cliFormat) + if got != pair.apiFormat { + t.Errorf("ConvertAccessType(%q) = %q, want %q", pair.cliFormat, got, pair.apiFormat) + } + }) + } +}