mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
* refactor(cmd): split Execute into Build with IO/Keychain injection
Introduce a public cmd.Build entry point so external consumers (cli-server,
MCP server, other embedders) can assemble the full CLI command tree without
going through os.Args or the platform keychain. Build takes an
InvocationContext plus functional BuildOptions:
* WithIO(in, out, errOut) — inject custom streams; terminal detection
is derived from the input's underlying *os.File when present.
* WithKeychain(kc) — swap the credential store.
* HideProfile(bool) — registered later in cmd.HideProfile.
The existing Execute() keeps using the internal buildInternal (which
still returns the Factory so error handling can attribute exit codes),
and SetDefaultFS replaces the global VFS implementation at startup.
Hardening applied up front:
* cmdutil.NewIOStreams(in, out, errOut) centralizes terminal detection
so SystemIO() and WithIO share one path.
* cmdutil.NewDefault normalizes partial IOStreams — callers may pass
&IOStreams{Out: buf} without tripping nil-writer panics in the
RoundTripper warnings, Cobra, or the credential provider.
* Build guards against nil functional options.
* An API contract test (cmd/build_api_test.go) exercises Build +
WithIO + WithKeychain + HideProfile + SetDefaultFS so the public
surface is reachable by deadcode analysis.
Change-Id: I7c895e6019817401accbde2db3ef800da40ad319
* feat(schema): filter methods by strict mode in schema output
When strict mode is active, schema output now excludes methods that
are incompatible with the forced identity. This applies to both
pretty and JSON output formats at the resource and method levels.
Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7
* refactor: centralize strict-mode as flag registration
Change-Id: Iec11151c5002c2f58a8aa067d08747db2e4d2d8c
* fix(cmd): align strict-mode completion and build context; drop dead register shims
Thread a context.Context through RegisterShortcuts, RegisterServiceCommands,
and service.registerService/Resource/Method by introducing explicit
*WithContext variants. Pass that context into NewCmdServiceMethodWithContext
so shortcut and service command construction can honor cancellation and
strict-mode pruning consistently.
Also drop the context-less registerMethod and registerResource shims —
they became unreachable once the WithContext variants took over, and
were the source of new deadcode warnings. registerService is retained
because service_test.go still calls it directly.
Change-Id: I3fe5673aed663c7383bbbc5b0ae94d1f3491f22d
* refactor(cmd): hide --profile in single-app mode via build option
- GlobalOptions gains HideProfile; RegisterGlobalFlags stays pure and reads
the policy off the struct. No boolean-trap parameter, one call per site.
- buildConfig holds GlobalOptions inline so HideProfile(bool) BuildOption
mutates it directly. buildInternal stays a pure assembly function and
requires callers to supply WithIO — no implicit os.Std* fallback.
- Add WithIO BuildOption (wrapping raw io.Reader/Writer with automatic
*os.File TTY detection); Execute injects streams explicitly and decides
profile visibility via HideProfile(isSingleAppMode()).
- installTipsHelpFunc force-shows hidden root flags while rendering the
root command's own help, so single-app users still discover --profile
via lark-cli --help without it polluting subcommand helps.
Change-Id: I7755387e993992ca969e0a4a6f54441cc1993eef
* feat(transport): extension abort hook and shared base transport
Two transport-layer changes bundled because both reshape the base
round-tripper contract used by the HTTP client, the Lark SDK client,
and the in-process updater.
1. Extension abort hook (PreRoundTripE).
Extensions implementing exttransport.AbortableInterceptor can now
return an error from PreRoundTripE to skip the built-in chain. The
post hook still fires with (nil, reason) so extensions can unwind
resources. extensionMiddleware captures the provider name so the
returned *AbortError carries attribution.
2. Shared base transport to stop RPC leak.
util.NewBaseTransport cloned http.DefaultTransport on every call, so
each cmdutil.Factory produced a fresh *http.Transport whose
persistConn readLoop/writeLoop goroutines lingered until
IdleConnTimeout (~90s). Invisible in a single-process CLI, but the
fork is consumed by cli-server where each RPC request constructs a
new Factory, causing linear memory + goroutine growth under load.
Replace NewBaseTransport with SharedTransport — returns
http.DefaultTransport (the stdlib-wide singleton) by default, and
a cached proxy-disabled clone only when LARK_CLI_NO_PROXY is set.
Return type is http.RoundTripper to discourage in-place mutation of
the shared instance. FallbackTransport is kept as a thin
*http.Transport wrapper so existing callers in internal/auth and
internal/cmdutil transport decorators (which were already on the
singleton path) do not have to migrate.
Leak-site migrations: factory_default.go (HTTP + SDK base) and
update.go now call SharedTransport directly.
Change-Id: Ia82462134c5c5ee838be878b887860f41446a235
* fix: unblock Build() zero-opts path and sidecar demo build
Two regressions surfaced on refactor/build-execute-split:
1. cmd.Build(ctx, inv) without WithIO panicked at rootCmd.SetIn/Out/Err
because cfg.streams stayed nil — NewDefault normalized internally
but cmd/build.go never saw the normalized value. Default cfg.streams
to cmdutil.SystemIO() before the root command wires them, and add a
TestBuild_NoOptions regression guard.
2. sidecar/server-demo/main.go still called cmdutil.NewDefault(inv),
so `go build -tags authsidecar_demo ./sidecar/server-demo` failed
with "not enough arguments". Pass nil for the new streams parameter
to preserve the prior behavior (NewDefault substitutes SystemIO).
Change-Id: I20227b2355cde7d19e22eba3eb841c6d8611e8a7
879 lines
27 KiB
Go
879 lines
27 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package service
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ── helpers ──
|
|
|
|
var testConfig = &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
}
|
|
|
|
func driveSpec() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"name": "drive",
|
|
"servicePath": "/open-apis/drive/v1",
|
|
}
|
|
}
|
|
|
|
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
|
|
m := map[string]interface{}{
|
|
"path": "files/{file_token}/copy",
|
|
"httpMethod": httpMethod,
|
|
}
|
|
if params != nil {
|
|
m["parameters"] = params
|
|
} else {
|
|
m["parameters"] = map[string]interface{}{
|
|
"file_token": map[string]interface{}{
|
|
"type": "string", "location": "path", "required": true,
|
|
},
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ── registerService ──
|
|
|
|
func TestRegisterService(t *testing.T) {
|
|
parent := &cobra.Command{Use: "root"}
|
|
f := &cmdutil.Factory{}
|
|
spec := map[string]interface{}{
|
|
"name": "base",
|
|
"description": "Base API",
|
|
"servicePath": "/open-apis/base/v3",
|
|
}
|
|
resources := map[string]interface{}{
|
|
"tables": map[string]interface{}{
|
|
"methods": map[string]interface{}{
|
|
"list": map[string]interface{}{
|
|
"description": "List tables",
|
|
"httpMethod": "GET",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
registerService(parent, spec, resources, f)
|
|
|
|
// service command exists
|
|
svc, _, err := parent.Find([]string{"base"})
|
|
if err != nil || svc.Name() != "base" {
|
|
t.Fatalf("expected 'base' command, got err=%v", err)
|
|
}
|
|
// resource sub-command
|
|
res, _, err := parent.Find([]string{"base", "tables"})
|
|
if err != nil || res.Name() != "tables" {
|
|
t.Fatalf("expected 'tables' command, got err=%v", err)
|
|
}
|
|
// method sub-command
|
|
meth, _, err := parent.Find([]string{"base", "tables", "list"})
|
|
if err != nil || meth.Name() != "list" {
|
|
t.Fatalf("expected 'list' command, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegisterService_MergesExistingCommand(t *testing.T) {
|
|
parent := &cobra.Command{Use: "root"}
|
|
existing := &cobra.Command{Use: "base", Short: "existing"}
|
|
parent.AddCommand(existing)
|
|
|
|
f := &cmdutil.Factory{}
|
|
spec := map[string]interface{}{
|
|
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
|
|
}
|
|
resources := map[string]interface{}{
|
|
"tables": map[string]interface{}{
|
|
"methods": map[string]interface{}{
|
|
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
|
},
|
|
},
|
|
}
|
|
|
|
registerService(parent, spec, resources, f)
|
|
|
|
// Should reuse existing, not duplicate
|
|
count := 0
|
|
for _, c := range parent.Commands() {
|
|
if c.Name() == "base" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 'base' command, got %d", count)
|
|
}
|
|
// Resource should be added under the existing command
|
|
_, _, err := parent.Find([]string{"base", "tables", "list"})
|
|
if err != nil {
|
|
t.Fatalf("expected 'list' under existing 'base' command, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
|
})
|
|
|
|
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
|
|
flag := cmd.Flags().Lookup("as")
|
|
if flag == nil {
|
|
t.Fatal("expected --as flag to be registered")
|
|
}
|
|
if !flag.Hidden {
|
|
t.Fatal("expected --as flag to be hidden in strict mode")
|
|
}
|
|
if got := flag.DefValue; got != "bot" {
|
|
t.Fatalf("default value = %q, want %q", got, "bot")
|
|
}
|
|
}
|
|
|
|
// ── NewCmdServiceMethod flags ──
|
|
|
|
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
|
f := &cmdutil.Factory{}
|
|
cmd := NewCmdServiceMethod(f, driveSpec(),
|
|
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
|
|
|
|
if cmd.Flags().Lookup("data") != nil {
|
|
t.Error("GET method should not have --data flag")
|
|
}
|
|
if cmd.Use != "list" {
|
|
t.Errorf("expected Use=list, got %s", cmd.Use)
|
|
}
|
|
if !strings.Contains(cmd.Long, "schema drive.files.list") {
|
|
t.Errorf("expected schema path in Long, got %s", cmd.Long)
|
|
}
|
|
}
|
|
|
|
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
|
|
f := &cmdutil.Factory{}
|
|
cmd := NewCmdServiceMethod(f, driveSpec(),
|
|
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
|
|
|
|
if cmd.Flags().Lookup("data") == nil {
|
|
t.Error("POST method should have --data flag")
|
|
}
|
|
}
|
|
|
|
func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
|
|
var captured *ServiceMethodOptions
|
|
cmd := NewCmdServiceMethod(f, driveSpec(),
|
|
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
|
func(opts *ServiceMethodOptions) error {
|
|
captured = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--as", "bot"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if captured == nil {
|
|
t.Fatal("runF was not called")
|
|
}
|
|
if captured.As != core.AsBot {
|
|
t.Errorf("expected As=bot, got %s", captured.As)
|
|
}
|
|
if captured.SchemaPath != "drive.files.list" {
|
|
t.Errorf("expected SchemaPath=drive.files.list, got %s", captured.SchemaPath)
|
|
}
|
|
}
|
|
|
|
// ── dry-run / buildServiceRequest ──
|
|
|
|
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fileToken string
|
|
wantInURL string
|
|
}{
|
|
{"normal token", "boxcn123abc", "/open-apis/drive/v1/files/boxcn123abc/copy"},
|
|
{"hyphen and underscore", "ou_abc-123_def", "/open-apis/drive/v1/files/ou_abc-123_def/copy"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
|
cmd.SetArgs([]string{
|
|
"--params", `{"file_token":"` + tt.fileToken + `"}`,
|
|
"--data", `{"name":"test.txt"}`,
|
|
"--dry-run",
|
|
})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), tt.wantInURL) {
|
|
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fileToken string
|
|
wantErr string
|
|
}{
|
|
{"path traversal with slashes", "../../auth/v3/token", "path traversal"},
|
|
{"single dot-dot", "../admin", "path traversal"},
|
|
{"question mark injection", "token?evil=true", "invalid characters"},
|
|
{"hash injection", "token#fragment", "invalid characters"},
|
|
{"percent-encoded bypass", "token%2F..%2Fadmin", "invalid characters"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
|
cmd.SetArgs([]string{
|
|
"--params", `{"file_token":"` + tt.fileToken + `"}`,
|
|
"--data", `{"name":"test.txt"}`,
|
|
"--dry-run",
|
|
})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for malicious path parameter")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("expected error containing %q, got: %v", tt.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_MissingPathParam(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
|
cmd.SetArgs([]string{"--params", `{}`, "--data", `{}`, "--dry-run"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for missing path param")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing required path parameter") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{
|
|
"path": "items", "httpMethod": "GET",
|
|
"parameters": map[string]interface{}{
|
|
"q": map[string]interface{}{"location": "query", "required": true},
|
|
},
|
|
}
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for missing required query param")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing required query parameter: q") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{
|
|
"path": "items", "httpMethod": "GET",
|
|
"parameters": map[string]interface{}{
|
|
"page_size": map[string]interface{}{"location": "query", "required": true},
|
|
},
|
|
}
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
|
|
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Dry Run") {
|
|
t.Error("expected dry-run output")
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "--params invalid format") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
|
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid --data JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "--data invalid JSON format") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
|
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error when both --params and --data use stdin")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
|
t.Errorf("expected stdin conflict error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --output + --page-all conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── bot mode integration with httpmock ──
|
|
|
|
func TestServiceMethod_BotMode_Success(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/svc/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"result": "success"},
|
|
},
|
|
})
|
|
|
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--as", "bot"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "success") {
|
|
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/svc/v1/items",
|
|
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
|
})
|
|
|
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--as", "bot"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected API error")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !isExitError(err, &exitErr) {
|
|
t.Fatalf("expected ExitError, got: %T %v", err, err)
|
|
}
|
|
if exitErr.Code != output.ExitAPI {
|
|
t.Errorf("expected ExitAPI code, got %d", exitErr.Code)
|
|
}
|
|
// stdout must be empty on API error — error details belong in stderr envelope only.
|
|
// This guards against re-introducing duplicate output (see commit 86215a10).
|
|
if stdout.Len() > 0 {
|
|
t.Errorf("expected no stdout on API error, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/svc/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), `"id"`) {
|
|
t.Errorf("expected items in output, got:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/svc/v1/items",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
})
|
|
|
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "warning: unknown format") {
|
|
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
|
|
}
|
|
}
|
|
|
|
// ── jq flag ──
|
|
|
|
func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
|
|
var captured *ServiceMethodOptions
|
|
cmd := NewCmdServiceMethod(f, driveSpec(),
|
|
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
|
func(opts *ServiceMethodOptions) error {
|
|
captured = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--jq", ".data"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if captured == nil {
|
|
t.Fatal("runF was not called")
|
|
}
|
|
if captured.JqExpr != ".data" {
|
|
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
|
}
|
|
}
|
|
|
|
func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
|
|
var captured *ServiceMethodOptions
|
|
cmd := NewCmdServiceMethod(f, driveSpec(),
|
|
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
|
func(opts *ServiceMethodOptions) error {
|
|
captured = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"-q", ".data"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if captured.JqExpr != ".data" {
|
|
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --jq + --output conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/svc/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"name": "Alice"},
|
|
map[string]interface{}{"name": "Bob"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
|
t.Errorf("expected jq-filtered names, got: %s", out)
|
|
}
|
|
if strings.Contains(out, `"code"`) {
|
|
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --jq + --format ndjson conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
spec := map[string]interface{}{
|
|
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
|
}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid jq expression")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid jq expression") {
|
|
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/svc/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
|
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") {
|
|
t.Errorf("expected jq-filtered ids, got: %s", out)
|
|
}
|
|
if strings.Contains(out, `"code"`) {
|
|
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// ── scopeAwareChecker ──
|
|
|
|
func TestScopeAwareChecker_Success(t *testing.T) {
|
|
checker := scopeAwareChecker(nil, false)
|
|
err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"})
|
|
if err != nil {
|
|
t.Errorf("expected nil error for code=0, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestScopeAwareChecker_NonMapResult(t *testing.T) {
|
|
checker := scopeAwareChecker(nil, false)
|
|
err := checker("not a map")
|
|
if err != nil {
|
|
t.Errorf("expected nil for non-map result, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestScopeAwareChecker_APIError(t *testing.T) {
|
|
checker := scopeAwareChecker(nil, false)
|
|
err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"})
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zero code")
|
|
}
|
|
if !strings.Contains(err.Error(), "API error: [40003]") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) {
|
|
scopes := []interface{}{"calendar:read"}
|
|
checker := scopeAwareChecker(scopes, false)
|
|
err := checker(map[string]interface{}{
|
|
"code": float64(output.LarkErrUserScopeInsufficient),
|
|
"msg": "scope insufficient",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected permission error")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !isExitError(err, &exitErr) {
|
|
t.Fatalf("expected ExitError, got %T", err)
|
|
}
|
|
if exitErr.Detail.Type != "permission" {
|
|
t.Errorf("expected type=permission, got %s", exitErr.Detail.Type)
|
|
}
|
|
if !strings.Contains(exitErr.Detail.Hint, "auth login") {
|
|
t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint)
|
|
}
|
|
}
|
|
|
|
func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
|
scopes := []interface{}{"calendar:read"}
|
|
checker := scopeAwareChecker(scopes, true)
|
|
err := checker(map[string]interface{}{
|
|
"code": float64(output.LarkErrUserScopeInsufficient),
|
|
"msg": "scope insufficient",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected permission error")
|
|
}
|
|
// Bot mode should still include the scope hint
|
|
if !strings.Contains(err.Error(), "insufficient permissions") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── file upload ──
|
|
|
|
func imImageMethod() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"path": "images",
|
|
"httpMethod": "POST",
|
|
"requestBody": map[string]interface{}{
|
|
"image_type": map[string]interface{}{
|
|
"type": "string",
|
|
"required": true,
|
|
},
|
|
"image": map[string]interface{}{
|
|
"type": "file",
|
|
"required": true,
|
|
},
|
|
},
|
|
"accessTokens": []interface{}{"user", "tenant"},
|
|
}
|
|
}
|
|
|
|
func imSpec() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"name": "im",
|
|
"servicePath": "/open-apis/im/v1",
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
|
flag := cmd.Flags().Lookup("file")
|
|
if flag == nil {
|
|
t.Fatal("expected --file flag to be registered for file upload method")
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
|
flag := cmd.Flags().Lookup("file")
|
|
if flag != nil {
|
|
t.Fatal("expected --file flag NOT to be registered for non-file method")
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
|
getMethod := map[string]interface{}{
|
|
"path": "images",
|
|
"httpMethod": "GET",
|
|
"requestBody": map[string]interface{}{
|
|
"image": map[string]interface{}{
|
|
"type": "file",
|
|
},
|
|
},
|
|
}
|
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
|
flag := cmd.Flags().Lookup("file")
|
|
if flag != nil {
|
|
t.Fatal("expected --file flag NOT to be registered for GET method")
|
|
}
|
|
}
|
|
|
|
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tmpFile := tmpDir + "/test.jpg"
|
|
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
|
cmd.SetArgs([]string{
|
|
"--file", "image=" + tmpFile,
|
|
"--data", `{"image_type":"message"}`,
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "image") {
|
|
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "Dry Run") {
|
|
t.Errorf("expected dry-run header, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestDetectFileFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
method map[string]interface{}
|
|
want []string
|
|
}{
|
|
{
|
|
name: "single file field",
|
|
method: map[string]interface{}{
|
|
"requestBody": map[string]interface{}{
|
|
"image": map[string]interface{}{"type": "file"},
|
|
"name": map[string]interface{}{"type": "string"},
|
|
},
|
|
},
|
|
want: []string{"image"},
|
|
},
|
|
{
|
|
name: "no file fields",
|
|
method: map[string]interface{}{
|
|
"requestBody": map[string]interface{}{
|
|
"name": map[string]interface{}{"type": "string"},
|
|
},
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "no requestBody",
|
|
method: map[string]interface{}{},
|
|
want: nil,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := detectFileFields(tt.method)
|
|
if len(got) != len(tt.want) {
|
|
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
|
return
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── helpers ──
|
|
|
|
func isExitError(err error, target **output.ExitError) bool {
|
|
ee, ok := err.(*output.ExitError)
|
|
if ok && target != nil {
|
|
*target = ee
|
|
}
|
|
return ok
|
|
}
|