diff --git a/cmd/config/bind.go b/cmd/config/bind.go index a95f356a6..7bd64f368 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -383,16 +383,17 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L } // priorLang returns the language preference recorded in a previous config, or -// "" if there is none / the bytes don't parse. +// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0] +// fallback) — scanning all apps for the first non-empty Lang would leak the +// wrong profile's preference into a re-bind when the workspace holds multiple +// named profiles and the active one disagrees with Apps[0]. func priorLang(previousConfigBytes []byte) i18n.Lang { var multi core.MultiAppConfig if json.Unmarshal(previousConfigBytes, &multi) != nil { return "" } - for _, app := range multi.Apps { - if app.Lang != "" { - return app.Lang - } + if app := multi.CurrentAppConfig(""); app != nil { + return app.Lang } return "" } diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index b645e3eb1..b45b1ca12 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -261,6 +261,53 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) { } } +// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps +// and silently returning a non-current profile's Lang. In a multi-profile +// workspace (set up via `profile add` before a re-bind), the active profile's +// Lang must win over a sibling profile that happens to sit earlier in the slice. +func TestPriorLang_RespectsCurrentApp(t *testing.T) { + multi := core.MultiAppConfig{ + CurrentApp: "active", + Apps: []core.AppConfig{ + {Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP}, + {Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS}, + }, + } + bytes, err := json.Marshal(multi) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := priorLang(bytes); got != i18n.LangEnUS { + t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS) + } +} + +// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy +// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0], +// so a bind-written config (which always has exactly one app and no +// CurrentApp field) still inherits its Lang. +func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) { + multi := core.MultiAppConfig{ + Apps: []core.AppConfig{ + {AppId: "cli_only", Lang: i18n.LangJaJP}, + }, + } + bytes, err := json.Marshal(multi) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := priorLang(bytes); got != i18n.LangJaJP { + t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP) + } +} + +// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch. +func TestPriorLang_MalformedReturnsEmpty(t *testing.T) { + if got := priorLang([]byte("not json")); got != "" { + t.Errorf("priorLang(malformed) = %q, want \"\"", got) + } +} + // TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope // "message" field against regressing to opts.Lang: when --lang is omitted on // re-bind, the inherited preference (appConfig.Lang) must drive the message diff --git a/internal/credential/integration_test.go b/internal/credential/integration_test.go index 7daef1d6a..de173d194 100644 --- a/internal/credential/integration_test.go +++ b/internal/credential/integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" ) @@ -115,3 +116,45 @@ func TestFullChain_ConfigStrictMode(t *testing.T) { t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities) } } + +// TestFullChain_LangSurvivesProductionPath exercises the exact data flow the +// production Factory uses (factory_default.go Phase 3): disk → multi config → +// DefaultAccountProvider.ResolveAccount → Account → ToCliConfig. If Lang gets +// dropped at the credential boundary (as it would when Account lacks the field), +// shortcuts/common/runner.go RuntimeContext.Lang() returns "" and downstream +// consumers (mail signature, etc.) silently fall back to defaults — defeating +// the whole point of persisting --lang. +func TestFullChain_LangSurvivesProductionPath(t *testing.T) { + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliAppSecret, "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cfg_app", + AppSecret: core.PlainSecret("cfg_secret"), + Brand: core.BrandFeishu, + Lang: i18n.LangJaJP, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "") + acct, err := defaultAcct.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount: %v", err) + } + if acct.Lang != i18n.LangJaJP { + t.Errorf("Account.Lang = %q, want %q (DefaultAccountProvider must propagate Lang from config)", acct.Lang, i18n.LangJaJP) + } + + cfg := acct.ToCliConfig() + if cfg == nil { + t.Fatal("ToCliConfig() = nil") + } + if cfg.Lang != i18n.LangJaJP { + t.Errorf("CliConfig.Lang = %q, want %q (this is the value RuntimeContext.Lang() reads in production)", cfg.Lang, i18n.LangJaJP) + } +} diff --git a/internal/credential/types.go b/internal/credential/types.go index 54620621d..98af4d475 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -10,6 +10,7 @@ import ( extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" ) // Account is the credential-layer view of the active runtime account. @@ -23,6 +24,7 @@ type Account struct { DefaultAs core.Identity UserOpenId string UserName string + Lang i18n.Lang SupportedIdentities uint8 } @@ -65,6 +67,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account { DefaultAs: cfg.DefaultAs, UserOpenId: cfg.UserOpenId, UserName: cfg.UserName, + Lang: cfg.Lang, SupportedIdentities: cfg.SupportedIdentities, } } @@ -82,6 +85,7 @@ func (a *Account) ToCliConfig() *core.CliConfig { DefaultAs: a.DefaultAs, UserOpenId: a.UserOpenId, UserName: a.UserName, + Lang: a.Lang, SupportedIdentities: a.SupportedIdentities, } } diff --git a/internal/credential/types_test.go b/internal/credential/types_test.go index b4feec7df..929d74fdb 100644 --- a/internal/credential/types_test.go +++ b/internal/credential/types_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" ) func TestTokenTypeString(t *testing.T) { @@ -53,6 +54,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { DefaultAs: "user", UserOpenId: "ou_123", UserName: "alice", + Lang: i18n.LangJaJP, SupportedIdentities: 3, } @@ -63,6 +65,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName { t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg) } + if acct.Lang != cfg.Lang { + t.Fatalf("AccountFromCliConfig().Lang = %q, want %q", acct.Lang, cfg.Lang) + } roundtrip := acct.ToCliConfig() if roundtrip == nil { @@ -71,6 +76,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName { t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg) } + if roundtrip.Lang != cfg.Lang { + t.Fatalf("ToCliConfig().Lang = %q, want %q (production Factory path reads Lang via this conversion)", roundtrip.Lang, cfg.Lang) + } roundtrip.AppID = "mutated-cli" acct.AppID = "mutated-account"