Files
larksuite__cli/cmd/root_integration_test.go
2026-09-10 10:37:00 +08:00

903 lines
31 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/apicatalog"
"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/meta"
"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, nil)
}
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"`
Params []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"params,omitempty"`
Rules []string `json:"rules,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()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *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))
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
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 keep +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())
}
resetBuffers(stdout, stderr)
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
code = executeRootIntegration(t, f, rootCmd, []string{"vc", "--help"})
if code != 0 {
t.Fatalf("vc --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(), "+search") {
t.Fatalf("vc --help should keep +search 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_MessagesSearchDryRunSucceeds(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", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
out := stdout.String()
if !strings.Contains(out, `"/open-apis/im/v1/messages/search"`) {
t.Fatalf("messages-search dry-run did not include search API; stdout:\n%s", out)
}
if !strings.Contains(out, `"identity":"bot"`) && !strings.Contains(out, `"identity": "bot"`) {
t.Fatalf("messages-search dry-run did not run as bot; stdout:\n%s", out)
}
}
func TestIntegration_StrictModeBot_ProfileOverride_VCSearchDryRunSucceeds(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"vc", "+search", "--query", "roadmap", "--page-size", "5", "--page-token", "next", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
out := stdout.String()
if !strings.Contains(out, `"/open-apis/vc/v1/meetings/search"`) {
t.Fatalf("vc +search dry-run did not include search API; stdout:\n%s", out)
}
if !strings.Contains(out, `"page_token":"next"`) && !strings.Contains(out, `"page_token": "next"`) {
t.Fatalf("vc +search dry-run did not preserve pagination; stdout:\n%s", out)
}
if !strings.Contains(out, `"identity":"bot"`) && !strings.Contains(out, `"identity": "bot"`) {
t.Fatalf("vc +search dry-run did not run as bot; stdout:\n%s", out)
}
}
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)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--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)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--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 ---
type conciseViewSafetyProvider struct {
wantTitle string
}
func (p *conciseViewSafetyProvider) Name() string { return "concise-fixture" }
func (p *conciseViewSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
encoded, err := json.Marshal(req.Data)
if err != nil {
return nil, err
}
// Match fields owned by conciseMessageView rather than the legacy output
// map, so this fixture fails if the command scans data other than the view
// that is passed to the Markdown renderer.
if !bytes.Contains(encoded, []byte(`"Title":`+fmt.Sprintf("%q", p.wantTitle))) ||
!bytes.Contains(encoded, []byte(`"ChatSections"`)) {
return nil, nil
}
return &extcs.Alert{Provider: p.Name(), MatchedRules: []string{"fixture-rule"}}, nil
}
type conciseIntegrationCommand struct {
name string
title string
args []string
}
func conciseIntegrationCommands() []conciseIntegrationCommand {
return []conciseIntegrationCommand{
{name: "chat-messages-list", title: "Chat messages", args: []string{"im", "+chat-messages-list", "--chat-id", "oc_test"}},
{name: "threads-messages-list", title: "Thread messages", args: []string{"im", "+threads-messages-list", "--thread", "omt_test"}},
}
}
func registerConciseMessageListStub(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/im/v1/messages",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{
"message_id": "om_fixture",
"msg_type": "text",
"body": map[string]interface{}{"content": `{"text":"fixture message"}`},
"create_time": "0",
}},
"has_more": false,
"page_token": "",
},
},
})
}
func TestIntegration_IMConciseContentSafety(t *testing.T) {
for _, command := range conciseIntegrationCommands() {
for _, mode := range []string{"warn", "block"} {
t.Run(command.name+"/"+mode, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
previousProvider := extcs.GetProvider()
extcs.Register(&conciseViewSafetyProvider{wantTitle: command.title})
t.Cleanup(func() { extcs.Register(previousProvider) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "concise-safety", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerConciseMessageListStub(reg)
rootCmd := buildIntegrationRootCmd(t, f)
args := append(append([]string{}, command.args...), "--as", "bot", "--concise", "--no-reactions")
code := executeRootIntegration(t, f, rootCmd, args)
if mode == "warn" {
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "# "+command.title) || !strings.Contains(stdout.String(), "fixture message") {
t.Fatalf("concise stdout = %q", stdout.String())
}
if !strings.Contains(stderr.String(), "warning: content safety alert from concise-fixture (rules: fixture-rule)") {
t.Fatalf("warn stderr = %q", stderr.String())
}
return
}
if code != output.ExitContentSafety {
t.Fatalf("exit code = %d, want %d", code, output.ExitContentSafety)
}
if stdout.Len() != 0 {
t.Fatalf("block stdout = %q, want empty", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
if env.Error.Type != "policy" || env.Error.Subtype != "content_safety" || len(env.Error.Rules) != 1 || env.Error.Rules[0] != "fixture-rule" {
t.Fatalf("block envelope = %#v", env)
}
})
}
}
}
func TestIntegration_IMConciseOutputFlagConflicts(t *testing.T) {
conflicts := []struct {
name string
args []string
flag string
}{
{name: "format", args: []string{"--format", "pretty"}, flag: "--format"},
{name: "json", args: []string{"--json"}, flag: "--json"},
{name: "jq", args: []string{"--jq", ".data"}, flag: "--jq"},
}
for _, command := range conciseIntegrationCommands() {
for _, conflict := range conflicts {
t.Run(command.name+"/"+conflict.name, func(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "concise-conflict", AppSecret: "secret", Brand: core.BrandFeishu,
})
requestCount := 0
reg.Register(&httpmock.Stub{
URL: "/open-apis/", Optional: true, Reusable: true,
OnMatch: func(*http.Request) { requestCount++ },
})
rootCmd := buildIntegrationRootCmd(t, f)
args := append(append([]string{}, command.args...), "--as", "bot", "--concise")
args = append(args, conflict.args...)
code := executeRootIntegration(t, f, rootCmd, args)
if code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", code, output.ExitValidation)
}
if stdout.Len() != 0 || requestCount != 0 {
t.Fatalf("stdout = %q, requests = %d; want empty stdout and no request", stdout.String(), requestCount)
}
env := parseTypedEnvelope(t, stderr)
if env.Error.Type != "validation" || env.Error.Param != "--concise" || !strings.Contains(env.Error.Message, conflict.flag) {
t.Fatalf("conflict envelope = %#v", env)
}
if len(env.Error.Params) != 2 || env.Error.Params[0].Name != "--concise" || env.Error.Params[1].Name != conflict.flag {
t.Fatalf("conflict params = %#v", env.Error.Params)
}
})
}
}
}
func TestIntegration_IMConciseDisabledOutputFlagsAreAllowed(t *testing.T) {
disabledFlags := []struct {
name string
arg string
}{
{name: "json-false", arg: "--json=false"},
{name: "jq-empty", arg: "--jq="},
}
for _, command := range conciseIntegrationCommands() {
for _, disabled := range disabledFlags {
t.Run(command.name+"/"+disabled.name, func(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "concise-disabled", AppSecret: "secret", Brand: core.BrandFeishu,
})
rootCmd := buildIntegrationRootCmd(t, f)
args := append(append([]string{}, command.args...), "--as", "bot", "--dry-run", "--concise", "--no-reactions", disabled.arg)
code := executeRootIntegration(t, f, rootCmd, args)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if envelope["ok"] != true || envelope["dry_run"] != true {
t.Fatalf("dry-run envelope = %#v", envelope)
}
})
}
}
}
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(nil)
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(nil)
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(nil)
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(nil)
// 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")
}