// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package cmd import ( "bytes" "context" "encoding/json" "os" "strings" "testing" "github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/shortcuts" "github.com/spf13/cobra" ) // Canonical strict-mode envelope messages shared across fixtures. The // switch-policy hint text is asserted by substring in // assertStrictModeDenialEnvelope. const ( strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available` strictModeUserMessage = `strict mode is "user", only user-identity commands are available` ) // buildIntegrationRootCmd creates a root command with api, service, and shortcut // subcommands wired to a test factory, simulating the real CLI command tree. func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { t.Helper() rootCmd := &cobra.Command{Use: "lark-cli"} rootCmd.SilenceErrors = true rootCmd.SetOut(f.IOStreams.Out) rootCmd.SetErr(f.IOStreams.ErrOut) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { cmd.SilenceUsage = true } rootCmd.AddCommand(api.NewCmdApi(f, nil)) service.RegisterServiceCommands(rootCmd, f) shortcuts.RegisterShortcuts(rootCmd, f) return rootCmd } // executeRootIntegration runs a command through the full command tree and // handleRootError, returning the exit code matching real CLI behavior. func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int { t.Helper() rootCmd.SetArgs(args) if err := rootCmd.Execute(); err != nil { return handleRootError(f, err) } return 0 } // typedErrorEnvelope mirrors the typed wire shape produced by // WriteTypedErrorEnvelope: the inner error marshals an errs.Problem // directly, so "type" is the category, "subtype" is top-level, and there // is no nested "detail" object. Recovery info (policy source, reason // code, suggestions) is folded into "hint". type typedErrorEnvelope struct { OK bool `json:"ok"` Identity string `json:"identity,omitempty"` Error struct { Type string `json:"type"` Subtype string `json:"subtype"` Message string `json:"message"` Hint string `json:"hint"` Param string `json:"param,omitempty"` } `json:"error"` } // parseTypedEnvelope decodes stderr as the typed envelope and fails if the // legacy nested "detail" object is present (the migration removed it). func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope { t.Helper() if stderr.Len() == 0 { t.Fatal("expected non-empty stderr, got empty") } var raw map[string]any if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil { t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String()) } if errObj, ok := raw["error"].(map[string]any); ok { if _, hasDetail := errObj["detail"]; hasDetail { t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String()) } } var env typedErrorEnvelope if err := json.Unmarshal(stderr.Bytes(), &env); err != nil { t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String()) } return env } func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { t.Helper() rootCmd := &cobra.Command{Use: "lark-cli"} rootCmd.SilenceErrors = true rootCmd.SetOut(f.IOStreams.Out) rootCmd.SetErr(f.IOStreams.ErrOut) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { cmd.SilenceUsage = true } rootCmd.AddCommand(auth.NewCmdAuth(f)) rootCmd.AddCommand(api.NewCmdApi(f, nil)) service.RegisterServiceCommands(rootCmd, f) shortcuts.RegisterShortcuts(rootCmd, f) if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() { pruneForStrictMode(rootCmd, mode) } return rootCmd } func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() t.Setenv(envvars.CliAppID, "") t.Setenv(envvars.CliAppSecret, "") t.Setenv(envvars.CliUserAccessToken, "") t.Setenv(envvars.CliTenantAccessToken, "") t.Setenv(envvars.CliDefaultAs, "") dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) targetMode := mode multi := &core.MultiAppConfig{ CurrentApp: "default", Apps: []core.AppConfig{ { Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu, }, { Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandFeishu, StrictMode: &targetMode, }, }, } if err := core.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} f := cmdutil.NewDefault( cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr), cmdutil.InvocationContext{Profile: profile}, ) return f, stdout, stderr } func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) { stdout.Reset() stderr.Reset() } // --- service command --- func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"}) if code != 0 { t.Fatalf("auth --help exit code = %d, want 0", code) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got: %s", stderr.String()) } if strings.Contains(stdout.String(), "login") { t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String()) } resetBuffers(stdout, stderr) rootCmd = buildStrictModeIntegrationRootCmd(t, f) code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"}) if code != 0 { t.Fatalf("im --help exit code = %d, want 0", code) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got: %s", stderr.String()) } if strings.Contains(stdout.String(), "+messages-search") { t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String()) } if !strings.Contains(stdout.String(), "+chat-create") { t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String()) } } func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "auth", "login", "--json", "--scope", "im:message.send_as_user", }) // auth login is user-only, so it gets pruned in strict-mode-bot and the // stub error fires (not login.go's inline check, which is shadowed by // pruning). The typed envelope is a failed_precondition validation // error (exit 2); the strict-mode layer + reason code are folded into // the hint. if code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } env := parseTypedEnvelope(t, stderr) assertStrictModeDenialEnvelope(t, env, strictModeBotMessage) } // assertStrictModeDenialEnvelope pins the shared strict-mode denial shape: // a validation/failed_precondition envelope whose message is the short // historical strict-mode line and whose hint still names the strict_mode // layer + identity_not_supported reason code (the safety-critical recovery // info), plus the historical switch-policy guidance. func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) { t.Helper() if env.OK { t.Errorf("envelope ok = true, want false") } if env.Error.Type != "validation" { t.Errorf("error.type = %q, want validation", env.Error.Type) } if env.Error.Subtype != "failed_precondition" { t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype) } if env.Error.Message != wantMessage { t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage) } if !strings.Contains(env.Error.Hint, "strict_mode") { t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint) } if !strings.Contains(env.Error.Hint, "identity_not_supported") { t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint) } if !strings.Contains(env.Error.Hint, "config strict-mode --help") { t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint) } } // assertCheckStrictModeEnvelope pins the typed envelope produced by // cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit // --as on shortcuts / service methods / api): a *errs.ValidationError with // subtype invalid_argument, the canonical strict-mode message, and the // switch-policy hint. func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) { t.Helper() if env.OK { t.Errorf("envelope ok = true, want false") } if env.Error.Type != "validation" { t.Errorf("error.type = %q, want validation", env.Error.Type) } if env.Error.Subtype != "invalid_argument" { t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype) } if env.Error.Message != wantMessage { t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage) } if !strings.Contains(env.Error.Hint, "config strict-mode --help") { t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint) } } func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello", }) if code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } env := parseTypedEnvelope(t, stderr) assertStrictModeDenialEnvelope(t, env, strictModeBotMessage) } func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) { // +chat-create supports both user and bot identities, so strict mode user // should allow it and force user identity. f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "im", "+chat-create", "--name", "probe", "--dry-run", }) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) } out := stdout.String() if out == "" { t.Fatal("expected non-empty stdout for dry-run") } } func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run", }) if code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } env := parseTypedEnvelope(t, stderr) assertCheckStrictModeEnvelope(t, env, strictModeUserMessage) } func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run", }) if code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } env := parseTypedEnvelope(t, stderr) assertCheckStrictModeEnvelope(t, env, strictModeBotMessage) } func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run", }) if code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } env := parseTypedEnvelope(t, stderr) assertStrictModeDenialEnvelope(t, env, strictModeUserMessage) } func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run", }) if code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } env := parseTypedEnvelope(t, stderr) assertCheckStrictModeEnvelope(t, env, strictModeBotMessage) } // --- shortcut command --- func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu, }) reg.Register(&httpmock.Stub{ URL: "/open-apis/im/v1/messages", Status: 400, Body: map[string]interface{}{ "code": 230002, "msg": "Bot/User can NOT be out of the chat.", }, }) rootCmd := buildIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ "im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test", }) // shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path. if code != output.ExitAPI { t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got:\n%s", stdout.String()) } if stderr.Len() == 0 { t.Fatal("expected non-empty stderr, got empty") } var raw struct { OK bool `json:"ok"` Identity string `json:"identity"` Error struct { Type string `json:"type"` Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil { t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String()) } if raw.OK { t.Errorf("envelope ok = true, want false") } if raw.Identity != "bot" { t.Errorf("identity = %q, want bot", raw.Identity) } if raw.Error.Type != "api" { t.Errorf("error.type = %q, want api", raw.Error.Type) } if raw.Error.Code != 230002 { t.Errorf("error.code = %d, want 230002", raw.Error.Code) } if raw.Error.Message != "Bot/User can NOT be out of the chat." { t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.") } } // TestSetupNotices_ColdStart_NoNotice verifies that missing state // produces no skills key in the composed notice. func TestSetupNotices_ColdStart_NoNotice(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) origVersion := build.Version build.Version = "1.0.21" t.Cleanup(func() { build.Version = origVersion }) // Reset pending state to ensure a clean test. skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil t.Cleanup(func() { skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil }) setupNotices() notice := output.GetNotice() if notice == nil { return // expected — no pending notices at all } if _, ok := notice["skills"]; ok { t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice) } } // TestSetupNotices_InSync verifies that matching state produces no // skills key in the composed notice. func TestSetupNotices_InSync(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } origVersion := build.Version build.Version = "1.0.21" t.Cleanup(func() { build.Version = origVersion }) skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil t.Cleanup(func() { skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil }) setupNotices() notice := output.GetNotice() if notice != nil { if _, ok := notice["skills"]; ok { t.Errorf("notice.skills present in in-sync state: %+v", notice) } } } // TestSetupNotices_Drift verifies mismatching state produces the // drift message with both current and target populated. func TestSetupNotices_Drift(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } origVersion := build.Version build.Version = "1.0.21" t.Cleanup(func() { build.Version = origVersion }) skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil t.Cleanup(func() { skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil }) setupNotices() notice := output.GetNotice() if notice == nil { t.Fatal("GetNotice() = nil, want non-nil for drift") } skills, ok := notice["skills"].(map[string]interface{}) if !ok { t.Fatalf("notice.skills missing, got %+v", notice) } if skills["current"] != "1.0.20" || skills["target"] != "1.0.21" { t.Errorf("notice.skills = %+v, want {current:\"1.0.20\", target:\"1.0.21\"}", skills) } want := "lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update" if msg, _ := skills["message"].(string); msg != want { t.Errorf("notice.skills.message = %q, want %q", msg, want) } if cmd, _ := skills["command"].(string); cmd != "lark-cli update" { t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update") } } // TestSetupNotices_BothUpdateAndSkills verifies the composed envelope // emits BOTH "_notice.update" and "_notice.skills" keys when each // pending value is set. Drives the skills key via setupNotices() (drift // state) and manually populates the update pending afterwards, since // clearNoticeEnv suppresses the update goroutine to avoid network // flakiness. func TestSetupNotices_BothUpdateAndSkills(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } origVersion := build.Version build.Version = "1.0.21" t.Cleanup(func() { build.Version = origVersion }) skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil t.Cleanup(func() { skillscheck.SetPending(nil) update.SetPending(nil) output.PendingNotice = nil }) setupNotices() // After setupNotices, skills pending is set (drift). Manually populate // the update side so the composed envelope has both keys — the update // goroutine is suppressed by clearNoticeEnv. update.SetPending(&update.UpdateInfo{Current: "1.0.21", Latest: "1.0.22"}) notice := output.GetNotice() if notice == nil { t.Fatal("GetNotice() = nil, want both keys") } if _, ok := notice["update"].(map[string]interface{}); !ok { t.Errorf("missing 'update' key: %+v", notice) } if _, ok := notice["skills"].(map[string]interface{}); !ok { t.Errorf("missing 'skills' key: %+v", notice) } upd, ok := notice["update"].(map[string]interface{}) if !ok { t.Fatalf("notice.update missing or wrong type: %+v", notice) } if cmd, _ := upd["command"].(string); cmd != "lark-cli update" { t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update") } sk, ok := notice["skills"].(map[string]interface{}) if !ok { t.Fatalf("notice.skills missing or wrong type: %+v", notice) } if cmd, _ := sk["command"].(string); cmd != "lark-cli update" { t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update") } } // clearNoticeEnv unsets the env vars that affect either notice. We // proactively SUPPRESS the update notifier (LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1) // because setupNotices spawns a goroutine that hits the npm registry — // tests focused on the skills check should not depend on network state. func clearNoticeEnv(t *testing.T) { t.Helper() for _, key := range []string{ "LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID", } { t.Setenv(key, "") os.Unsetenv(key) } // Suppress the update goroutine's network call deterministically. t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") }