Files
larksuite-cli/internal/credential/integration_test.go
liangshuo-1 ce2abff8ae 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
2026-05-28 20:53:15 +08:00

161 lines
4.9 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential_test
import (
"context"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"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"
)
type noopKC struct{}
func (n *noopKC) Get(service, account string) (string, error) { return "", nil }
func (n *noopKC) Set(service, account, value string) error { return nil }
func (n *noopKC) Remove(service, account string) error { return nil }
func TestFullChain_EnvWins(t *testing.T) {
t.Setenv(envvars.CliAppID, "env_app")
t.Setenv(envvars.CliAppSecret, "env_secret")
t.Setenv(envvars.CliUserAccessToken, "env_uat")
ep := &envprovider.Provider{}
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
nil, nil, nil,
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.AppID != "env_app" {
t.Errorf("expected env_app, got %s", acct.AppID)
}
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "env_app",
})
if err != nil {
t.Fatal(err)
}
if result.Token != "env_uat" {
t.Errorf("expected env_uat, got %s", result.Token)
}
}
func TestFullChain_Fallthrough(t *testing.T) {
// env provider returns nil (no env vars set), falls through to default token
ep := &envprovider.Provider{}
mock := &mockDefaultTokenProvider{token: "mock_tok", scopes: "drive:read"}
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
nil, mock, nil,
)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "app1",
})
if err != nil {
t.Fatal(err)
}
if result.Token != "mock_tok" || result.Scopes != "drive:read" {
t.Errorf("unexpected: %+v", result)
}
}
type mockDefaultTokenProvider struct {
token string
scopes string
}
func (m *mockDefaultTokenProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: m.token, Scopes: m.scopes}, nil
}
func TestFullChain_ConfigStrictMode(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
botMode := core.StrictModeBot
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "cfg_app",
AppSecret: core.PlainSecret("cfg_secret"),
Brand: core.BrandLark,
StrictMode: &botMode,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
ep := &envprovider.Provider{}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
defaultAcct, nil, nil,
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.SupportedIdentities != uint8(extcred.SupportsBot) {
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)
}
}