// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package config import ( "context" "errors" "io" "os" "path/filepath" "strings" "testing" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" ) type noopConfigKeychain struct{} func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil } func (n *noopConfigKeychain) Set(service, account, value string) error { return nil } func (n *noopConfigKeychain) Remove(service, account string) error { return nil } type recordingConfigKeychain struct { removed []string } func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil } func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil } func (r *recordingConfigKeychain) Remove(service, account string) error { r.removed = append(r.removed, service+":"+account) return nil } func TestConfigInitCmd_FlagParsing(t *testing.T) { clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts f, _, _, _ := cmdutil.TestFactory(t, nil) f.IOStreams.In = strings.NewReader("secret123\n") var gotOpts *ConfigInitOptions cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { gotOpts = opts return nil }) cmd.SetArgs([]string{"--app-id", "cli_test", "--app-secret-stdin", "--brand", "lark"}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } if gotOpts.AppID != "cli_test" { t.Errorf("expected AppID cli_test, got %s", gotOpts.AppID) } if !gotOpts.AppSecretStdin { t.Error("expected AppSecretStdin=true") } if gotOpts.Brand != "lark" { t.Errorf("expected Brand lark, got %s", gotOpts.Brand) } } func TestConfigShowCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) var gotOpts *ConfigShowOptions cmd := NewCmdConfigShow(f, func(opts *ConfigShowOptions) error { gotOpts = opts return nil }) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } if gotOpts == nil { t.Error("expected opts to be set") } } func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, _, _ := cmdutil.TestFactory(t, nil) err := configShowRun(&ConfigShowOptions{Factory: f}) if err == nil { t.Fatal("expected error") } var cfgErr *core.ConfigError if !errors.As(err, &cfgErr) { t.Fatalf("error type = %T, want *core.ConfigError", err) } // Config errors share ExitAuth (3), not ExitValidation. if cfgErr.Code != output.ExitAuth { t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth) } if cfgErr.Type != "config" || cfgErr.Message != "not configured" { t.Fatalf("detail = %+v, want config/not configured", cfgErr) } } func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) multi := &core.MultiAppConfig{ CurrentApp: "missing", Apps: []core.AppConfig{{ Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu, }}, } if err := core.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } f, _, _, _ := cmdutil.TestFactory(t, nil) err := configShowRun(&ConfigShowOptions{Factory: f}) if err == nil { t.Fatal("expected error") } if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth) } if !strings.Contains(err.Error(), "no active profile") { t.Fatalf("error = %v, want to contain 'no active profile'", err) } } func TestConfigInitCmd_LangFlag(t *testing.T) { clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts f, _, _, _ := cmdutil.TestFactory(t, nil) var gotOpts *ConfigInitOptions cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { gotOpts = opts return nil }) f.IOStreams.In = strings.NewReader("y\n") cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin", "--lang", "en"}) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } // --lang en is canonicalized to en_us in RunE before runF captures opts. if gotOpts.Lang != string(i18n.LangEnUS) { t.Errorf("expected Lang en_us, got %s", gotOpts.Lang) } if !gotOpts.langExplicit { t.Error("expected langExplicit=true when --lang is passed") } } func TestConfigInitCmd_LangDefault(t *testing.T) { clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts f, _, _, _ := cmdutil.TestFactory(t, nil) var gotOpts *ConfigInitOptions cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { gotOpts = opts return nil }) f.IOStreams.In = strings.NewReader("y\n") cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin"}) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } if gotOpts.Lang != "" { t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang) } if gotOpts.langExplicit { t.Error("expected langExplicit=false when --lang is not passed") } } // TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path: // re-running init without --lang must inherit the prior preference, not clear it. func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, _, _ := cmdutil.TestFactory(t, nil) existing := &core.MultiAppConfig{Apps: []core.AppConfig{ {AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP}, }} if err := core.SaveMultiAppConfig(existing); err != nil { t.Fatalf("seed config: %v", err) } if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil { t.Fatalf("saveInitConfig (no --lang): %v", err) } got, err := core.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig: %v", err) } if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP { t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP) } } // TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is // strictly validated the same way bind validates: wrong-case / typo / removed // codes / hyphen form all exit with ExitValidation. (Empty is a no-op.) func TestConfigInitCmd_InvalidLang(t *testing.T) { clearAgentEnv(t) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cases := []struct { name string lang string }{ {"wrong case ZH", "ZH"}, {"typo frr", "frr"}, {"removed code ar", "ar"}, {"unknown xx", "xx"}, {"hyphen form zh-CN", "zh-CN"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) cmd := NewCmdConfigInit(f, nil) f.IOStreams.In = strings.NewReader("sec\n") cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"}) err := cmd.Execute() if err == nil { t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) } exitErr, ok := err.(*output.ExitError) if !ok { t.Fatalf("expected *output.ExitError, got %T: %v", err, err) } if exitErr.Code != output.ExitValidation { t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) } if !strings.Contains(exitErr.Error(), "invalid --lang") { t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) } }) } } func TestHasAnyNonInteractiveFlag(t *testing.T) { tests := []struct { name string opts ConfigInitOptions want bool }{ {"empty", ConfigInitOptions{}, false}, {"new", ConfigInitOptions{New: true}, true}, {"app-id", ConfigInitOptions{AppID: "x"}, true}, {"app-secret-stdin", ConfigInitOptions{AppSecretStdin: true}, true}, {"app-id+secret-stdin", ConfigInitOptions{AppID: "x", AppSecretStdin: true}, true}, {"lang-only", ConfigInitOptions{Lang: "en"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.opts.hasAnyNonInteractiveFlag() if got != tt.want { t.Errorf("hasAnyNonInteractiveFlag() = %v, want %v", got, tt.want) } }) } } func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) // TestFactory has IsTerminal=false by default opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "zh"} err := configInitRun(opts) if err == nil { t.Fatal("expected error for non-terminal without flags") } msg := err.Error() if !strings.Contains(msg, "--new") { t.Errorf("expected error to mention --new, got: %s", msg) } if !strings.Contains(msg, "terminal") { t.Errorf("expected error to mention terminal, got: %s", msg) } } func TestConfigRemoveCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) var gotOpts *ConfigRemoveOptions cmd := NewCmdConfigRemove(f, func(opts *ConfigRemoveOptions) error { gotOpts = opts return nil }) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } if gotOpts == nil { t.Fatal("expected opts to be set") } if gotOpts.Factory != f { t.Fatal("expected factory to be preserved in options") } } func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) { configDir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) multi := &core.MultiAppConfig{ Apps: []core.AppConfig{{ AppId: "app-test", AppSecret: core.SecretInput{ Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"}, }, Brand: core.BrandFeishu, Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}}, }}, } if err := core.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } kc := &recordingConfigKeychain{} f, _, _, _ := cmdutil.TestFactory(t, nil) f.Keychain = kc // Make subsequent config saves fail while keeping the existing config readable. if err := os.Chmod(configDir, 0500); err != nil { t.Fatalf("Chmod(%s) error = %v", configDir, err) } defer os.Chmod(configDir, 0700) err := configRemoveRun(&ConfigRemoveOptions{Factory: f}) if err == nil { t.Fatal("expected save failure") } if !strings.Contains(err.Error(), "failed to save config") { t.Fatalf("error = %v, want failed to save config", err) } if len(kc.removed) != 0 { t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed) } // Restore permissions and confirm the original config is still intact. if err := os.Chmod(configDir, 0700); err != nil { t.Fatalf("restore Chmod(%s) error = %v", configDir, err) } saved, err := core.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" { t.Fatalf("saved config = %#v, want original single app preserved", saved) } if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" { t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got) } configPath := filepath.Join(configDir, "config.json") if _, err := os.Stat(configPath); err != nil { t.Fatalf("expected existing config file to remain, stat error = %v", err) } } func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) existing := &core.MultiAppConfig{ Apps: []core.AppConfig{ { Name: "prod", AppId: "cli_prod", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu, }, }, } err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en") if err == nil { t.Fatal("expected conflict error") } if !strings.Contains(err.Error(), "conflicts with existing appId") { t.Fatalf("error = %v, want conflict with existing appId", err) } } func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) { multi := &core.MultiAppConfig{ CurrentApp: "prod", Apps: []core.AppConfig{ { Name: "prod", AppId: "app-old", AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}}, Brand: core.BrandFeishu, Lang: "zh", Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}}, }, }, } err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en") if err == nil { t.Fatal("expected error when changing app ID without a new secret") } if !strings.Contains(err.Error(), "App Secret") { t.Fatalf("error = %v, want mention of App Secret", err) } } // stubConfigExtProvider simulates env/sidecar credential mode for config guard tests. type stubConfigExtProvider struct{ name string } func (s *stubConfigExtProvider) Name() string { return s.name } func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) { return &extcred.Account{AppID: "test-app"}, nil } func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) { return nil, nil } func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory { t.Helper() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) stub := &stubConfigExtProvider{name: "env"} cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil) f, _, _, _ := cmdutil.TestFactory(t, nil) f.Credential = cred return f } func TestConfigBlockedByExternalProvider(t *testing.T) { f := newConfigFactoryWithExternalProvider(t) tests := []struct { name string args []string }{ {"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}}, {"remove", []string{"remove"}}, {"show", []string{"show"}}, {"default-as", []string{"default-as", "user"}}, {"strict-mode", []string{"strict-mode", "off"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := NewCmdConfig(f) cmd.SilenceErrors = true cmd.SetErr(io.Discard) cmd.SetArgs(tt.args) // Locate the subcommand before execution (PersistentPreRunE receives it as cmd). matched, _, _ := cmd.Find(tt.args) err := cmd.Execute() // PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent. if matched != nil && matched != cmd && !matched.SilenceUsage { t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") } if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } }) } } // TestValidateInitLang covers the --lang contract: empty (omitted or explicit) // is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to // the same locale; an unrecognized value errors. func TestValidateInitLang(t *testing.T) { t.Run("empty is a no-op", func(t *testing.T) { for _, explicit := range []bool{false, true} { opts := &ConfigInitOptions{Lang: "", langExplicit: explicit} if err := validateInitLang(opts); err != nil { t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err) } if opts.Lang != "" { t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang) } } }) t.Run("short and locale canonicalize alike", func(t *testing.T) { for _, in := range []string{"ja", "ja_jp"} { opts := &ConfigInitOptions{Lang: in, langExplicit: true} if err := validateInitLang(opts); err != nil { t.Fatalf("--lang %q: unexpected error %v", in, err) } if opts.Lang != string(i18n.LangJaJP) { t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP) } } }) } // TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints // to stderr only when --lang explicitly set a non-empty preference. func TestPrintLangPreferenceConfirmation(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Run("explicit non-empty prints confirmation", func(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true}) got := stderr.String() if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") { t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got) } }) t.Run("implicit prints nothing", func(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false}) if got := stderr.String(); got != "" { t.Errorf("stderr = %q, want empty when --lang is implicit", got) } }) t.Run("explicit empty prints nothing", func(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true}) if got := stderr.String(); got != "" { t.Errorf("stderr = %q, want empty when --lang is empty", got) } }) }