mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
fix(config): propagate Lang across credential boundary; respect CurrentApp in priorLang (#1157)
Two issues caught in review of #1132 that the existing tests missed because they constructed RuntimeContext/CliConfig directly, bypassing the credential edge where the bug lives. P1 — Lang dropped at credential boundary credential.Account had no Lang field, so AccountFromCliConfig and ToCliConfig silently dropped cfg.Lang. The production Factory builds CliConfig via acct.ToCliConfig() (factory_default.go Phase 3), which meant RuntimeContext.Lang() always returned "" in production and shortcuts/mail/mail_signature.go always fell back to zh_cn — defeating the whole point of persisting --lang. Fix: add Lang i18n.Lang to Account and copy it in both directions. Regression test: TestFullChain_LangSurvivesProductionPath walks the real path (SaveMultiAppConfig -> DefaultAccountProvider.ResolveAccount -> ToCliConfig) and asserts Lang survives, so any future field added to CliConfig forces the same audit. P2 — priorLang ignored CurrentApp in multi-profile workspaces priorLang scanned all Apps and returned the first non-empty Lang. If a user had multiple profiles and the active one disagreed with Apps[0], a re-bind without --lang would silently inherit the wrong profile's preference. Fix: read multi.CurrentAppConfig("").Lang instead. Regression tests cover CurrentApp wins over Apps[0], single-app fallback, and malformed bytes. Change-Id: If7a276605f84f398cec329c2c942b471b4c32749
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user