fix(api): support stdin and quoted JSON inputs on Windows (#367)

* fix(api): add stdin and single-quote support for --params/--data on Windows (#64)

Windows PowerShell 5.x mangles JSON double-quotes when passing arguments
to native executables, causing --params and --data to fail with
"invalid JSON format". This commit adds two mitigations at the framework
level:

- stdin piping: `echo '{"k":"v"}' | lark-cli --params -` bypasses
  shell argument parsing entirely and works on all platforms/shells.
- single-quote stripping: cmd.exe passes literal single quotes which
  are now transparently removed before JSON parsing.

Implementation:
- New `cmdutil.ResolveInput(raw, stdin)` handles `-` (stdin), strip
  surrounding `'...'`, and plain passthrough.
- `ParseJSONMap` and `ParseOptionalBody` now accept an `io.Reader` and
  delegate to `ResolveInput` before JSON unmarshalling.
- `cmd/api` and `cmd/service` pass `IOStreams.In` and guard against
  simultaneous stdin usage by --params and --data.
- Empty stdin is rejected with a clear error message.

Closes #64

Change-Id: If21e735d0aed5c6a2d6674c1e6c898186fca3aba

* test: add stdin e2e regression coverage

Change-Id: I4e00bf1c6b6f3259f503e3414cae10fa4b34ba75
This commit is contained in:
liangshuo-1
2026-04-09 19:10:50 +08:00
committed by GitHub
parent eb3c643f0b
commit 619ec8c2cb
11 changed files with 562 additions and 44 deletions

View File

@@ -5,7 +5,6 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
@@ -44,17 +43,6 @@ type APIOptions struct {
DryRun bool
}
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
if input == "" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
func normalisePath(raw string) string {
@@ -88,8 +76,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -118,19 +106,19 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
// buildAPIRequest validates flags and builds a RawApiRequest.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
params, err := parseJsonOpt(opts.Params, "--params")
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, err
}
if params == nil {
params = map[string]interface{}{}
}
var data interface{}
if opts.Data != "" {
data, err = parseJsonOpt(opts.Data, "--data")
if err != nil {
return client.RawApiRequest{}, err
}
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize

View File

@@ -199,6 +199,22 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
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 TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -5,7 +5,6 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
@@ -148,10 +147,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
}
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -310,13 +309,15 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
var params map[string]interface{}
if opts.Params != "" {
if err := json.Unmarshal([]byte(opts.Params), &params); err != nil {
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
}
} else {
params = map[string]interface{}{}
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, err
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -365,7 +366,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
}

View File

@@ -308,7 +308,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "--params invalid JSON format") {
if !strings.Contains(err.Error(), "--params invalid format") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -331,6 +331,24 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
}
}
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{}{

View File

@@ -5,35 +5,46 @@ package cmdutil
import (
"encoding/json"
"io"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
if data == "" {
resolved, err := ResolveInput(data, stdin)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(data), &body); err != nil {
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
func ParseJSONMap(input, label string) (map[string]any, error) {
if input == "" {
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}
if resolved == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(input), &result); err != nil {
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data)
got, err := ParseOptionalBody(tt.method, tt.data, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label)
got, err := ParseJSONMap(tt.input, tt.label, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"strings"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
if raw == "" {
return "", nil
}
// stdin
if raw == "-" {
if stdin == nil {
return "", fmt.Errorf("stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("stdin is empty (did you forget to pipe input?)")
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
}
return raw, nil
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"strings"
"testing"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"key":"value"}` {
t.Errorf("got %q, want %q", got, `{"key":"value"}`)
}
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q, want %q", got, `{"k":"v"}`)
}
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
if err == nil {
t.Error("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("expected 'stdin is empty' error, got: %v", err)
}
}
type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
if err == nil {
t.Error("expected error for nil stdin")
}
}
func TestResolveInput_StripSingleQuotes(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"cmd.exe JSON", `'{"key":"value"}'`, `{"key":"value"}`},
{"cmd.exe empty", `'{}'`, `{}`},
{"no quotes", `{"key":"value"}`, `{"key":"value"}`},
{"just quotes", `''`, ``},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"already":"valid"}` {
t.Errorf("got %q, want %q", got, `{"already":"valid"}`)
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["key"] != "value" {
t.Errorf("got %v, want key=value", got)
}
}
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil body")
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
// Simulates exact strings Go receives on different Windows shells.
func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantErr bool
}{
{"bash: normal JSON", `{"a":"1","b":"2"}`, 2, false},
{"cmd.exe: single-quoted", `'{"a":"1","b":"2"}'`, 2, false}, // strip ' fix
{"PS 5.x: mangled", `{a:1,b:2}`, 0, true}, // unrecoverable
{"PS 5.x: empty JSON OK", `{}`, 0, false}, // no inner "
{"PS 7.3+: normal JSON", `{"a":"1"}`, 1, false}, // already fixed
{"PS escaped: correct", `{"a":"1"}`, 1, false}, // after CommandLineToArgvW
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("got %d keys, want %d", len(got), tt.wantLen)
}
})
}
}

View File

@@ -36,6 +36,9 @@ type Request struct {
Params any
// Data is optional and becomes --data '<json>' when non-nil.
Data any
// Stdin is optional and becomes the child process stdin when non-nil.
// Use an empty slice to exercise empty-stdin behavior explicitly.
Stdin []byte
// BinaryPath is optional. Empty means: LARK_CLI_BIN, project-root ./lark-cli, then PATH.
BinaryPath string
// DefaultAs is optional and becomes --as <value> when non-empty.
@@ -77,6 +80,9 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if req.Stdin != nil {
cmd.Stdin = bytes.NewReader(req.Stdin)
}
cmd.Stdout = &stdout
cmd.Stderr = &stderr

View File

@@ -215,6 +215,19 @@ func TestRunCmd(t *testing.T) {
assert.ErrorIs(t, result.RunErr, context.DeadlineExceeded)
assert.Equal(t, 0, fake.ReadSetCount(t))
})
t.Run("passes stdin to process", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-stdin"},
Stdin: []byte("hello from stdin\n"),
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "hello from stdin\n", result.Stdout)
})
}
type fakeCLI struct {
@@ -277,6 +290,11 @@ if [ "$1" = "emit-arg" ]; then
exit 1
fi
if [ "$1" = "emit-stdin" ]; then
cat
exit 0
fi
exit_code=0
while [ "$#" -gt 0 ]; do
case "$1" in

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package clie2e
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCLIStdinRegression_SuccessCases(t *testing.T) {
setDryRunConfigEnv(t)
tests := []struct {
name string
req Request
assertions func(*testing.T, *Result)
}{
{
name: "api reads params from stdin",
req: Request{
Args: []string{"api", "GET", "/open-apis/test", "--params", "-", "--dry-run"},
Stdin: []byte(`{"a":"1","b":"2"}` + "\n"),
},
assertions: func(t *testing.T, result *Result) {
entry := firstDryRunRequest(t, result.Stdout)
assert.Equal(t, "GET", entry["method"])
assert.Equal(t, "/open-apis/test", entry["url"])
assert.Equal(t, map[string]any{"a": "1", "b": "2"}, entry["params"])
},
},
{
name: "api reads data from stdin",
req: Request{
Args: []string{"api", "POST", "/open-apis/test", "--data", "-", "--dry-run"},
Stdin: []byte(`{"text":"hello"}` + "\n"),
},
assertions: func(t *testing.T, result *Result) {
entry := firstDryRunRequest(t, result.Stdout)
assert.Equal(t, "POST", entry["method"])
assert.Equal(t, map[string]any{"text": "hello"}, entry["body"])
},
},
{
name: "api strips single quoted json",
req: Request{
Args: []string{"api", "GET", "/open-apis/test", "--params", `'{"a":"1"}'`, "--dry-run"},
},
assertions: func(t *testing.T, result *Result) {
entry := firstDryRunRequest(t, result.Stdout)
assert.Equal(t, map[string]any{"a": "1"}, entry["params"])
},
},
{
name: "service reads params from stdin",
req: Request{
Args: []string{
"calendar", "events", "instance_view",
"--as", "bot",
"--params", "-",
"--dry-run",
},
Stdin: []byte(`{"calendar_id":"primary","start_time":"1700000000","end_time":"1700003600"}` + "\n"),
},
assertions: func(t *testing.T, result *Result) {
entry := firstDryRunRequest(t, result.Stdout)
assert.Equal(t, "GET", entry["method"])
assert.Equal(t, "/open-apis/calendar/v4/calendars/primary/events/instance_view", entry["url"])
assert.Equal(t, map[string]any{
"start_time": "1700000000",
"end_time": "1700003600",
}, entry["params"])
},
},
{
name: "service reads data from stdin",
req: Request{
Args: []string{
"task", "tasks", "create",
"--as", "bot",
"--data", "-",
"--dry-run",
},
Stdin: []byte(`{"summary":"stdin regression"}` + "\n"),
},
assertions: func(t *testing.T, result *Result) {
entry := firstDryRunRequest(t, result.Stdout)
assert.Equal(t, "POST", entry["method"])
assert.Equal(t, "/open-apis/task/v2/tasks", entry["url"])
assert.Equal(t, map[string]any{"summary": "stdin regression"}, entry["body"])
},
},
{
name: "service strips single quoted json",
req: Request{
Args: []string{
"task", "tasks", "create",
"--as", "bot",
"--data", `'{"summary":"single quote"}'`,
"--dry-run",
},
},
assertions: func(t *testing.T, result *Result) {
entry := firstDryRunRequest(t, result.Stdout)
assert.Equal(t, map[string]any{"summary": "single quote"}, entry["body"])
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := RunCmd(context.Background(), tt.req)
require.NoError(t, err)
require.NoError(t, result.RunErr, "stderr:\n%s", result.Stderr)
result.AssertExitCode(t, 0)
tt.assertions(t, result)
})
}
}
func TestCLIStdinRegression_ErrorCases(t *testing.T) {
setDryRunConfigEnv(t)
tests := []struct {
name string
req Request
wantMessage string
}{
{
name: "api rejects empty stdin",
req: Request{
Args: []string{"api", "GET", "/open-apis/test", "--params", "-", "--dry-run"},
Stdin: []byte{},
},
wantMessage: "--params: stdin is empty (did you forget to pipe input?)",
},
{
name: "api rejects double stdin",
req: Request{
Args: []string{"api", "POST", "/open-apis/test", "--params", "-", "--data", "-", "--dry-run"},
Stdin: []byte(`{"x":1}` + "\n"),
},
wantMessage: "--params and --data cannot both read from stdin (-)",
},
{
name: "service rejects empty stdin",
req: Request{
Args: []string{
"calendar", "events", "instance_view",
"--as", "bot",
"--params", "-",
"--dry-run",
},
Stdin: []byte{},
},
wantMessage: "--params: stdin is empty (did you forget to pipe input?)",
},
{
name: "service rejects double stdin",
req: Request{
Args: []string{
"task", "tasks", "create",
"--as", "bot",
"--params", "-",
"--data", "-",
"--dry-run",
},
Stdin: []byte(`{"summary":"stdin regression"}` + "\n"),
},
wantMessage: "--params and --data cannot both read from stdin (-)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := RunCmd(context.Background(), tt.req)
require.NoError(t, err)
assert.Error(t, result.RunErr)
result.AssertExitCode(t, 2)
envelope, ok := result.StderrJSON(t).(map[string]any)
require.True(t, ok)
assert.Equal(t, false, envelope["ok"])
errDetail, ok := envelope["error"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "validation", errDetail["type"])
assert.Equal(t, tt.wantMessage, errDetail["message"])
})
}
}
func setDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func firstDryRunRequest(t *testing.T, stdout string) map[string]any {
t.Helper()
const prefix = "=== Dry Run ===\n"
if !strings.HasPrefix(stdout, prefix) {
t.Fatalf("expected dry-run prefix, got:\n%s", stdout)
}
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(stdout, prefix)), &payload); err != nil {
t.Fatalf("parse dry-run payload: %v\nstdout:\n%s", err, stdout)
}
apiEntries, ok := payload["api"].([]any)
require.True(t, ok, "payload missing api array: %#v", payload)
require.Len(t, apiEntries, 1)
entry, ok := apiEntries[0].(map[string]any)
require.True(t, ok, "api entry is not an object: %#v", apiEntries[0])
return entry
}