mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
903 lines
31 KiB
Go
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")
|
|
}
|