mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
46
internal/cmdutil/resolve.go
Normal file
46
internal/cmdutil/resolve.go
Normal 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
|
||||
}
|
||||
189
internal/cmdutil/resolve_test.go
Normal file
189
internal/cmdutil/resolve_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
225
tests/cli_e2e/stdin_regression_test.go
Normal file
225
tests/cli_e2e/stdin_regression_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user