From 676e40b77e303b6560ef601ed9dc15976afdc0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E7=A1=95?= Date: Thu, 9 Apr 2026 17:18:12 +0800 Subject: [PATCH 1/2] 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 --- cmd/api/api.go | 36 ++---- cmd/api/api_test.go | 16 +++ cmd/service/service.go | 23 ++-- cmd/service/service_test.go | 20 +++- internal/cmdutil/json.go | 23 +++- internal/cmdutil/json_test.go | 4 +- internal/cmdutil/resolve.go | 46 ++++++++ internal/cmdutil/resolve_test.go | 189 +++++++++++++++++++++++++++++++ 8 files changed, 313 insertions(+), 44 deletions(-) create mode 100644 internal/cmdutil/resolve.go create mode 100644 internal/cmdutil/resolve_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go index 3467ef2ee..b26c1167e 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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 diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 589cc21e0..94d553209 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -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, diff --git a/cmd/service/service.go b/cmd/service/service.go index 61759fa67..592a73874 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -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), ¶ms); 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 } diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 7cd09f398..c424610dc 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -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{}{ diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index 6a162c4ef..65c639b46 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -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 diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index e88218a11..fed7f927c 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -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 diff --git a/internal/cmdutil/resolve.go b/internal/cmdutil/resolve.go new file mode 100644 index 000000000..a40475680 --- /dev/null +++ b/internal/cmdutil/resolve.go @@ -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 +} diff --git a/internal/cmdutil/resolve_test.go b/internal/cmdutil/resolve_test.go new file mode 100644 index 000000000..54396cc2d --- /dev/null +++ b/internal/cmdutil/resolve_test.go @@ -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) + } + }) + } +} From 7de0af641c341d3abad567e99c1ff897d3f81690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E7=A1=95?= Date: Thu, 9 Apr 2026 17:39:16 +0800 Subject: [PATCH 2/2] test: add stdin e2e regression coverage Change-Id: I4e00bf1c6b6f3259f503e3414cae10fa4b34ba75 --- internal/cmdutil/resolve_test.go | 2 +- tests/cli_e2e/core.go | 6 + tests/cli_e2e/core_test.go | 18 ++ tests/cli_e2e/stdin_regression_test.go | 225 +++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/cli_e2e/stdin_regression_test.go diff --git a/internal/cmdutil/resolve_test.go b/internal/cmdutil/resolve_test.go index 54396cc2d..c83f188cf 100644 --- a/internal/cmdutil/resolve_test.go +++ b/internal/cmdutil/resolve_test.go @@ -169,7 +169,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) { }{ {"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: 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 diff --git a/tests/cli_e2e/core.go b/tests/cli_e2e/core.go index 3b6a54bb9..47f4ad3fb 100644 --- a/tests/cli_e2e/core.go +++ b/tests/cli_e2e/core.go @@ -36,6 +36,9 @@ type Request struct { Params any // Data is optional and becomes --data '' 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 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 diff --git a/tests/cli_e2e/core_test.go b/tests/cli_e2e/core_test.go index 88cd34981..70b0e05ae 100644 --- a/tests/cli_e2e/core_test.go +++ b/tests/cli_e2e/core_test.go @@ -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 diff --git a/tests/cli_e2e/stdin_regression_test.go b/tests/cli_e2e/stdin_regression_test.go new file mode 100644 index 000000000..29e5a78aa --- /dev/null +++ b/tests/cli_e2e/stdin_regression_test.go @@ -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 +}