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:
liangshuo-1
2026-05-28 20:53:15 +08:00
committed by GitHub
parent 893555a1b1
commit ce2abff8ae
5 changed files with 108 additions and 5 deletions

View File

@@ -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 ""
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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,
}
}

View File

@@ -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"