chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/spf13/cobra"
)
const skipAuthCheckKey = "skipAuthCheck"
const annotationSupportedIdentities = "lark:supportedIdentities"
// SetSupportedIdentities marks which identities a command supports.
func SetSupportedIdentities(cmd *cobra.Command, identities []string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[annotationSupportedIdentities] = strings.Join(identities, ",")
}
// GetSupportedIdentities returns the declared identities, or nil if not declared.
func GetSupportedIdentities(cmd *cobra.Command) []string {
v, ok := cmd.Annotations[annotationSupportedIdentities]
if !ok || v == "" {
return nil
}
return strings.Split(v, ",")
}
// DisableAuthCheck marks a command (and all its children) as not requiring auth.
func DisableAuthCheck(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[skipAuthCheckKey] = "true"
}
// IsAuthCheckDisabled returns true if the command or any ancestor has auth check disabled.
func IsAuthCheckDisabled(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations != nil && c.Annotations[skipAuthCheckKey] == "true" {
return true
}
}
return false
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestDisableAuthCheck(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if IsAuthCheckDisabled(cmd) {
t.Error("expected auth check to be enabled by default")
}
DisableAuthCheck(cmd)
if !IsAuthCheckDisabled(cmd) {
t.Error("expected auth check to be disabled after DisableAuthCheck")
}
}
func TestIsAuthCheckDisabled_Inheritance(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
if IsAuthCheckDisabled(child) {
t.Error("expected child auth check enabled before parent annotation")
}
DisableAuthCheck(parent)
if !IsAuthCheckDisabled(child) {
t.Error("expected child to inherit disabled auth check from parent")
}
}
func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
DisableAuthCheck(child)
if IsAuthCheckDisabled(parent) {
t.Error("parent should not inherit disabled auth check from child")
}
if !IsAuthCheckDisabled(child) {
t.Error("child should have disabled auth check")
}
}
func TestSetGetSupportedIdentities(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if got := GetSupportedIdentities(cmd); got != nil {
t.Errorf("expected nil, got %v", got)
}
SetSupportedIdentities(cmd, []string{"user", "bot"})
got := GetSupportedIdentities(cmd)
if len(got) != 2 || got[0] != "user" || got[1] != "bot" {
t.Errorf("expected [user bot], got %v", got)
}
}
func TestSetSupportedIdentities_OverwriteExisting(t *testing.T) {
cmd := &cobra.Command{Use: "test", Annotations: map[string]string{"other": "val"}}
SetSupportedIdentities(cmd, []string{"bot"})
if cmd.Annotations["other"] != "val" {
t.Error("existing annotation should be preserved")
}
got := GetSupportedIdentities(cmd)
if len(got) != 1 || got[0] != "bot" {
t.Errorf("expected [bot], got %v", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"sync/atomic"
"github.com/spf13/cobra"
)
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Default to disabled (zero value = false) and let
// callers that actually serve a completion request opt in via
// SetFlagCompletionsEnabled(true).
var flagCompletionsEnabled atomic.Bool
// SetFlagCompletionsEnabled toggles whether RegisterFlagCompletion actually
// registers callbacks with cobra. Typically set once at process start.
func SetFlagCompletionsEnabled(enabled bool) {
flagCompletionsEnabled.Store(enabled)
}
// FlagCompletionsEnabled reports the current switch state.
func FlagCompletionsEnabled() bool {
return flagCompletionsEnabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if !flagCompletionsEnabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsEnabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
if FlagCompletionsEnabled() {
t.Fatal("expected default false (completions disabled by default)")
}
SetFlagCompletionsEnabled(true)
if !FlagCompletionsEnabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsEnabled(false)
if FlagCompletionsEnabled() {
t.Fatal("expected false after Set(false)")
}
}
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsEnabled(false)
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
const N = 5
var collected atomic.Int32
func() {
for range N {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
}
}()
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
for range 30 {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
if got := collected.Load(); int(got) != N {
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
}
}
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsEnabled(true)
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
want := []cobra.Completion{"a", "b"}
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return want, cobra.ShellCompDirectiveNoFileComp
})
fn, ok := cmd.GetFlagCompletionFunc("foo")
if !ok {
t.Fatal("expected completion func to be registered")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected completion result: %v", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/errs"
)
// RequireConfirmation constructs a typed *errs.ConfirmationRequiredError
// (exit code ExitConfirmationRequired) carrying the risk level and action as
// typed extension fields. Used by both shortcut and service command execution
// paths when a statically high-risk-write operation has not been confirmed
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestRequireConfirmation_TypedShape(t *testing.T) {
err := RequireConfirmation("drive +delete")
if err == nil {
t.Fatal("expected non-nil error")
}
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
}
if cre.Category != errs.CategoryConfirmation {
t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation)
}
if cre.Subtype != errs.SubtypeConfirmationRequired {
t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired)
}
if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired {
t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired)
}
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
}
if cre.Action != "drive +delete" {
t.Errorf("Action = %q, want drive +delete", cre.Action)
}
}
func TestRequireConfirmation_JSONShape(t *testing.T) {
err := RequireConfirmation("mail +send")
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
}
raw, mErr := json.Marshal(cre)
if mErr != nil {
t.Fatalf("marshal: %v", mErr)
}
var back map[string]interface{}
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
if back["risk"] != "high-risk-write" {
t.Errorf("risk in JSON = %v", back["risk"])
}
if back["action"] != "mail +send" {
t.Errorf("action in JSON = %v", back["action"])
}
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
if _, has := back["upgraded_by"]; has {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}
+297
View File
@@ -0,0 +1,297 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"net/url"
"sort"
"strings"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params,omitempty"`
Body interface{} `json:"body,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
return &DryRunAPI{extra: map[string]interface{}{}}
}
// --- HTTP method builders (add a call, return self for chaining) ---
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Body = body
}
return d
}
// Params sets query parameters on the last added call.
func (d *DryRunAPI) Params(params map[string]interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Params = params
}
return d
}
// Desc sets a description on the last added call.
// If no calls exist yet, sets the top-level description.
func (d *DryRunAPI) Desc(desc string) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Desc = desc
} else {
d.desc = desc
}
return d
}
// Set adds an extra context field. Values are also used to resolve :key placeholders in URLs.
func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
d.extra[key] = value
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
resolved := make([]DryRunAPICall, len(d.calls))
for i, c := range d.calls {
resolved[i] = DryRunAPICall{
Desc: c.Desc,
Method: c.Method,
URL: d.resolveURL(c.URL),
Params: c.Params,
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+2)
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
for k, v := range d.extra {
m[k] = v
}
return json.Marshal(m)
}
// Format renders the dry-run output as plain text for AI/human consumption.
func (d *DryRunAPI) Format() string {
var b strings.Builder
if d.desc != "" {
b.WriteString("# ")
b.WriteString(d.desc)
b.WriteByte('\n')
}
for i, c := range d.calls {
if i > 0 || d.desc != "" {
b.WriteByte('\n')
}
if c.Desc != "" {
b.WriteString("# ")
b.WriteString(c.Desc)
b.WriteByte('\n')
}
u := d.resolveURL(c.URL)
if len(c.Params) > 0 {
u += "?" + encodeParams(c.Params)
}
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
if !util.IsNil(c.Body) {
j, _ := json.Marshal(c.Body)
b.WriteString(" ")
b.Write(j)
b.WriteByte('\n')
}
}
if len(d.calls) == 0 && len(d.extra) > 0 {
if d.desc != "" {
b.WriteByte('\n')
}
keys := make([]string, 0, len(d.extra))
for k := range d.extra {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
sv := dryRunFormatValue(d.extra[k])
if sv == "" {
continue
}
b.WriteString(k)
b.WriteString(": ")
b.WriteString(sv)
b.WriteByte('\n')
}
}
return b.String()
}
func dryRunFormatValue(v interface{}) string {
switch val := v.(type) {
case string:
return val
case nil:
return ""
default:
j, _ := json.Marshal(val)
return string(j)
}
}
func encodeParams(params map[string]interface{}) string {
vals := url.Values{}
for k, v := range params {
vals.Set(k, fmt.Sprintf("%v", v))
}
return vals.Encode()
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
filePathDisplay := filePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": fileField, "path": filePathDisplay},
}
if formFields != nil {
fileInfo["form_fields"] = formFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
func TestDryRunAPI_SingleGET(t *testing.T) {
dr := NewDryRunAPI().
Desc("list calendars").
GET("/open-apis/calendar/v4/calendars")
text := dr.Format()
if !strings.Contains(text, "# list calendars") {
t.Errorf("expected description in text output, got: %s", text)
}
if !strings.Contains(text, "GET /open-apis/calendar/v4/calendars") {
t.Errorf("expected GET line in text output, got: %s", text)
}
}
func TestDryRunAPI_WithParams(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/test").
Params(map[string]interface{}{"page_size": 20})
text := dr.Format()
if !strings.Contains(text, "page_size=20") {
t.Errorf("expected query params in text output, got: %s", text)
}
}
func TestDryRunAPI_WithBody(t *testing.T) {
dr := NewDryRunAPI().
POST("/open-apis/test").
Body(map[string]interface{}{"title": "hello"})
text := dr.Format()
if !strings.Contains(text, "POST /open-apis/test") {
t.Errorf("expected POST line, got: %s", text)
}
if !strings.Contains(text, `"title"`) {
t.Errorf("expected body in output, got: %s", text)
}
}
func TestDryRunAPI_ResolveURL(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/calendar/v4/calendars/:calendar_id/events").
Set("calendar_id", "cal_abc123")
text := dr.Format()
if !strings.Contains(text, "cal_abc123") {
t.Errorf("expected resolved calendar_id in URL, got: %s", text)
}
if strings.Contains(text, ":calendar_id") {
t.Errorf("expected placeholder to be resolved, got: %s", text)
}
}
func TestDryRunAPI_MarshalJSON(t *testing.T) {
dr := NewDryRunAPI().
Desc("test api").
GET("/open-apis/test").
Set("as", "user")
data, err := json.Marshal(dr)
if err != nil {
t.Fatalf("MarshalJSON failed: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
t.Errorf("expected 1 api call, got: %v", m["api"])
}
}
func TestDryRunAPI_MultipleCalls(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/first").Desc("step 1").
POST("/open-apis/second").Desc("step 2")
text := dr.Format()
if !strings.Contains(text, "# step 1") || !strings.Contains(text, "# step 2") {
t.Errorf("expected both step descriptions, got: %s", text)
}
if !strings.Contains(text, "GET /open-apis/first") || !strings.Contains(text, "POST /open-apis/second") {
t.Errorf("expected both calls, got: %s", text)
}
}
func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
dr := NewDryRunAPI().
Desc("info only").
Set("calendar_id", "cal_123").
Set("summary", "My Calendar")
text := dr.Format()
if !strings.Contains(text, "calendar_id: cal_123") {
t.Errorf("expected extra field, got: %s", text)
}
if !strings.Contains(text, "summary: My Calendar") {
t.Errorf("expected extra field, got: %s", text)
}
}
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, "json")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, got: %s", out)
}
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, "pretty")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "POST /open-apis/test") {
t.Errorf("expected POST line in pretty output, got: %s", out)
}
}
func TestDryRunFormatValue(t *testing.T) {
tests := []struct {
name string
v interface{}
want string
}{
{"string", "hello", "hello"},
{"nil", nil, ""},
{"number", 42, "42"},
{"bool", true, "true"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dryRunFormatValue(tt.v); got != tt.want {
t.Errorf("dryRunFormatValue(%v) = %q, want %q", tt.v, got, tt.want)
}
})
}
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
)
// Factory holds shared dependencies injected into every command.
// All function fields are lazily initialized and cached after first call.
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
}
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
if f == nil || f.FileIOProvider == nil {
return nil
}
return f.FileIOProvider.ResolveFileIO(ctx)
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on credential hints.
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
f.IdentityAutoDetected = false
if cmd != nil && cmd.Flags().Changed("as") {
if flagAs != core.AsAuto {
f.ResolvedIdentity = flagAs
return flagAs
}
// --as auto: fall through to auto-detect
}
mode := f.ResolveStrictMode(ctx)
// Strict mode forces implicit identity choices. Explicit --as user/bot is
// preserved above so CheckStrictMode can reject incompatible requests.
if forced := mode.ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
hint := f.resolveIdentityHint(ctx)
if cmd == nil || !cmd.Flags().Changed("as") {
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
f.ResolvedIdentity = defaultAs
return f.ResolvedIdentity
}
}
// Auto-detect based on credential hint
f.IdentityAutoDetected = true
result := autoDetectIdentityFromHint(hint)
f.ResolvedIdentity = result
return result
}
func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity {
if hint != nil {
return hint.DefaultAs
}
return ""
}
func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
if hint != nil && hint.AutoAs != "" {
return hint.AutoAs
}
return core.AsBot
}
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
if f.Credential == nil {
return nil
}
hint, err := f.Credential.ResolveIdentityHint(ctx)
if err != nil {
return nil
}
return hint
}
// CheckIdentity verifies the resolved identity is in the supported list.
// On success, sets f.ResolvedIdentity. On failure, returns an error
// tailored to whether the identity was explicit (--as) or auto-detected.
func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
for _, t := range supported {
if string(as) == t {
f.ResolvedIdentity = as
return nil
}
}
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
base := errs.NewValidationError(errs.SubtypeInvalidArgument,
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s",
as, list).
WithParam("--as")
if len(supported) > 0 {
return base.WithHint("use --as %s", supported[0])
}
return base
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--as %s is not supported, this command only supports: %s", as, list).
WithParam("--as")
}
// ResolveStrictMode returns the effective strict mode by reading
// Account.SupportedIdentities from the credential provider chain.
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if f.Credential == nil {
return core.StrictModeOff
}
acct, err := f.Credential.ResolveAccount(ctx)
if err != nil || acct == nil {
return core.StrictModeOff
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
switch {
case ids.BotOnly():
return core.StrictModeBot
case ids.UserOnly():
return core.StrictModeUser
default:
return core.StrictModeOff
}
}
// CheckStrictMode returns an error if strict mode is active and identity is not allowed.
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
return f.NewAPIClientWithConfig(cfg)
}
// NewAPIClientWithConfig creates an APIClient with an explicit config.
// Use this when the caller has already resolved the correct config.
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
sdk, err := f.LarkClient()
if err != nil {
return nil, err
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
errOut := io.Discard
if f.IOStreams != nil {
errOut = f.IOStreams.ErrOut
}
return &client.APIClient{
Config: cfg,
SDK: sdk,
HTTP: httpClient,
ErrOut: errOut,
Credential: f.Credential,
}, nil
}
// RequireBuiltinCredentialProvider returns a typed validation error when an
// extension provider is actively managing credentials. Intended for use as
// PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%q is not supported: credentials are provided externally and do not support interactive management", command).
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
// NewDefault creates a production Factory with cached closures.
// Initialization follows a credential-first order:
//
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
}
// Workspace detection: determines which config subtree to use.
// Must run before any config or credential load, since those paths are
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
cfg := acct.ToCliConfig()
registry.InitWithBrand(cfg.Brand)
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
return f
}
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
// redirects; other headers like X-Cli-* pass through.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del("X-Lark-MCP-UAT")
req.Header.Del("X-Lark-MCP-TAT")
}
return nil
}
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
// needed because the real function is guarded by an internal sync.Once, so
// calling it directly would only fire on the first test (see
// factory_proxy_warn_test.go). The terminal check is the IOStreams
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
client := &http.Client{
Transport: rt,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
return client, nil
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
opts := []lark.ClientOptionFunc{
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
providers := extcred.Providers()
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
// happens before the command runs, so any plain-text warning written
// to stderr would break the JSON envelope contract that AI agents
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"testing"
"github.com/larksuite/cli/errs"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
type countingFileIOProvider struct {
resolveCalls int
}
func (p *countingFileIOProvider) Name() string { return "counting" }
func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
p.resolveCalls++
return &localfileio.LocalFileIO{}
}
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
bot := core.StrictModeBot
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.PlainSecret("secret-target"),
Brand: core.BrandFeishu,
StrictMode: &bot,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(nil, InvocationContext{Profile: "target"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
if cfg.ProfileName != "target" {
t.Fatalf("Config() profile = %q, want %q", cfg.ProfileName, "target")
}
if cfg.AppID != "app-target" {
t.Fatalf("Config() appID = %q, want %q", cfg.AppID, "app-target")
}
}
func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(nil, InvocationContext{Profile: "missing"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
}
_, err := f.Config()
if err == nil {
t.Fatal("Config() error = nil, want non-nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("Config() error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Message != `profile "missing" not found` {
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)
}
}
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Fatalf("ResolveAs() = %q, want %q", got, core.AsUser)
}
if f.IdentityAutoDetected {
t.Fatal("IdentityAutoDetected = true, want false")
}
}
func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "uat-token")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
cfg.AppID = "mutated-cli-config"
if acct.AppID != "env-app" {
t.Fatalf("credential account mutated via Config(): got %q, want %q", acct.AppID, "env-app")
}
}
func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "uat-token")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct.AppSecret != "" {
t.Fatalf("credential account AppSecret = %q, want empty string", acct.AppSecret)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
if cfg.AppSecret != "" {
t.Fatalf("Config().AppSecret = %q, want empty string for token-only account", cfg.AppSecret)
}
if credential.HasRealAppSecret(cfg.AppSecret) {
t.Fatalf("Config().AppSecret = %q, want token-only no-secret marker", cfg.AppSecret)
}
}
func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
prev := fileio.GetProvider()
provider := &countingFileIOProvider{}
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(nil, InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
}
if got := f.ResolveFileIO(context.Background()); got == nil {
t.Fatal("ResolveFileIO() = nil, want non-nil")
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"testing"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
t.Fatalf("first call: %v", err)
}
if c1 == nil {
t.Fatal("first call returned nil")
}
c2, err := fn()
if err != nil {
t.Fatalf("second call: %v", err)
}
if c1 != c2 {
t.Error("expected same *http.Client instance on second call (cache hit)")
}
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
}
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
}
}
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/envvars"
)
// installProxyWarnSpy replaces warnIfProxied with a counter for one test and
// restores it on cleanup. Returns a pointer to the call count so the caller can
// assert how many times the warning fired. The terminal state is controlled via
// the IOStreams.StderrIsTerminal field, not a seam.
func installProxyWarnSpy(t *testing.T) *int {
t.Helper()
prevWarn := warnIfProxied
t.Cleanup(func() { warnIfProxied = prevWarn })
calls := 0
warnIfProxied = func(io.Writer) { calls++ }
return &calls
}
var proxyWarnGateCases = []struct {
name string
terminal bool
want int
}{
{"terminal stderr warns once", true, 1},
{"non-terminal stderr stays silent", false, 0},
}
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
if *calls != tc.want {
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
}
})
}
}
// TestCachedLarkClientFunc_ProxyWarnGate verifies the lark-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal. The gate
// runs after ResolveAccount, so an env-backed credential is wired up to let
// account resolution succeed without network or config files.
func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
calls := installProxyWarnSpy(t)
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
t.Fatalf("lark client init: %v", err)
}
if *calls != tc.want {
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
}
})
}
}
+472
View File
@@ -0,0 +1,472 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("as", "auto", "identity")
if changed {
_ = cmd.Flags().Set("as", asValue)
}
return cmd
}
// --- ResolveAs tests ---
func TestResolveAs_ExplicitAs(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for explicit --as")
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAsUser(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("want user, got %s", got)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
// --as auto explicitly: should fall through to auto-detect
// Config has no UserOpenId → auto-detect returns bot
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (auto-detect, no login), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true for auto-detect path")
}
}
func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "bot",
})
cmd := newCmdWithAsFlag("auto", false) // --as not changed
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (from default-as config), got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for default-as path")
}
}
func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) {
t.Setenv(envvars.CliDefaultAs, "user")
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (env default-as should not bypass config source), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true when no account default-as is set")
}
}
func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
// default-as = "auto" should fall through to auto-detect
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "auto",
})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
// No UserOpenId → auto-detect returns bot
if got != core.AsBot {
t.Errorf("want bot (auto-detect), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true")
}
}
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.ResolveAs(context.Background(), nil, "auto")
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
}
// --- CheckIdentity tests ---
func TestCheckIdentity_Supported(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsBot, []string{"bot", "user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Supported_UserOnly(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsUser, []string{"user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Unsupported_Explicit(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = false // explicit --as
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--as user is not supported") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "bot") {
t.Errorf("error should mention supported identity: %v", err)
}
}
func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(ve.Message, "resolved identity") {
t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message)
}
if !strings.Contains(ve.Hint, "use --as bot") {
t.Errorf("expected hint to suggest --as bot, got: %v", ve.Hint)
}
}
// --- NewAPIClient / NewAPIClientWithConfig tests ---
func TestNewAPIClient(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClient()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
}
func TestNewAPIClientWithConfig(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
if ac.SDK == nil {
t.Error("SDK should not be nil")
}
if ac.HTTP == nil {
t.Error("HTTP should not be nil")
}
}
func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
f.IOStreams = nil
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac == nil {
t.Fatal("expected non-nil APIClient")
}
}
// --- ResolveStrictMode tests ---
func TestResolveStrictMode_Off(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off, got %q", got)
}
}
func TestResolveStrictMode_BotFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Errorf("expected bot, got %q", got)
}
}
func TestResolveStrictMode_UserFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser {
t.Errorf("expected user, got %q", got)
}
}
func TestResolveStrictMode_BothIdentities(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off when both supported, got %q", got)
}
}
func TestResolveStrictMode_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.Credential = nil
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off with nil credential, got %q", got)
}
}
// --- CheckStrictMode tests ---
func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed in bot mode, got: %v", err)
}
}
func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error for user in bot mode")
}
if !strings.Contains(err.Error(), "strict mode") {
t.Errorf("error should mention strict mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed in user mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsBot)
if err == nil {
t.Fatal("expected error for bot in user mode")
}
}
func TestCheckStrictMode_Off_BothAllowed(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed when off: %v", err)
}
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed when off: %v", err)
}
}
// --- ResolveAs strict mode tests ---
func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should force bot, got %s", got)
}
}
func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Errorf("user mode should force user, got %s", got)
}
}
func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("explicit bot should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit bot in user mode")
}
}
func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("explicit user should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit user in bot mode")
}
}
func TestResolveAs_StrictModeUser_ExplicitAutoForcesUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, core.AsAuto)
if got != core.AsUser {
t.Errorf("--as auto should use strict-mode user identity, got %s", got)
}
}
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should override default-as user, got %s", got)
}
}
// stubExtProvider is a minimal extcred.Provider for testing external-provider guards.
type stubExtProvider struct {
name string
acct *extcred.Account
err error
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
}
func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Message == "" {
t.Error("expected non-empty message")
}
if ve.Hint == "" {
t.Error("expected non-empty hint")
}
}
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ParseFileFlag parses a --file flag value into its components.
// The format is either "path" or "field=path". When no explicit "field="
// prefix is present, defaultField is used as the field name.
// A path of "-" indicates stdin; in that case filePath is empty and isStdin is true.
func ParseFileFlag(raw, defaultField string) (fieldName, filePath string, isStdin bool) {
if idx := strings.IndexByte(raw, '='); idx > 0 {
fieldName = raw[:idx]
filePath = raw[idx+1:]
} else {
fieldName = defaultField
filePath = raw
}
if filePath == "-" {
return fieldName, "", true
}
return fieldName, filePath, false
}
// ValidateFileFlag checks mutual exclusion rules for the --file flag.
// Returns nil if file is empty (flag not provided).
func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpMethod string) error {
if file == "" {
return nil
}
_, filePath, isStdin := ParseFileFlag(file, "file")
if !isStdin && filePath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path").
WithParam("--file")
}
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --output are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --output"},
errs.InvalidParam{Name: "--output", Reason: "mutually exclusive with --file"},
)
}
if pageAll {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --page-all are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "mutually exclusive with --file"},
)
}
if isStdin && data == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --data cannot both read from stdin").WithParams(
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--data", Reason: "only one flag may read from stdin"},
)
}
if isStdin && params == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --params cannot both read from stdin").WithParams(
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--params", Reason: "only one flag may read from stdin"},
)
}
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file requires POST, PUT, PATCH, or DELETE method").
WithParam("--file").
WithHint("file upload only applies to write methods; remove --file for read methods")
}
return nil
}
// FileUploadMeta holds file upload metadata for dry-run display.
// Returned by request builders when dry-run mode skips actual file reading.
type FileUploadMeta struct {
FieldName string
FilePath string
FormFields any
}
// BuildFormdata constructs a multipart form data payload for file upload.
// If isStdin is true, the file content is read from stdin.
// Top-level keys from dataJSON are added as text form fields.
func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin bool, stdin io.Reader, dataJSON any) (*larkcore.Formdata, error) {
fd := larkcore.NewFormdata()
if isStdin {
if stdin == nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "--file: stdin is not available").
WithParam("--file").
WithHint("pipe the file content to stdin, or pass a file path instead of \"-\"")
}
data, err := io.ReadAll(stdin)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err).
WithParam("--file").
WithCause(err)
}
if len(data) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty").
WithParam("--file").
WithHint("pipe non-empty file content to stdin")
}
fd.AddFile(fieldName, bytes.NewReader(data))
} else {
f, err := fileIO.Open(filePath)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath).
WithParam("--file").
WithCause(err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err).
WithParam("--file").
WithCause(err)
}
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.
if m, ok := dataJSON.(map[string]any); ok {
for k, v := range m {
fd.AddField(k, formatFormFieldValue(v))
}
}
return fd, nil
}
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
// field string. float64 is handled specially: fmt's default %v/%g switches to
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
// backends reject when parsing the field as an integer. Use decimal notation
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
// All other types fall through to %v.
func formatFormFieldValue(v any) string {
if n, ok := v.(float64); ok {
return strconv.FormatFloat(n, 'f', -1, 64)
}
return fmt.Sprintf("%v", v)
}
+429
View File
@@ -0,0 +1,429 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// failingReader always errors on Read, to exercise stdin read-failure paths.
type failingReader struct{ err error }
func (r *failingReader) Read([]byte) (int, error) { return 0, r.err }
// requireFileValidationError asserts err is a typed *errs.ValidationError with
// the expected subtype, exit code 2 (legacy ErrValidation parity), and a
// param diagnostic referencing --file (either Param or one of Params).
func requireFileValidationError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.ValidationError {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation)
}
mentionsFile := valErr.Param == "--file"
for _, p := range valErr.Params {
if p.Name == "--file" {
mentionsFile = true
}
}
if !mentionsFile {
t.Errorf("expected --file in Param/Params, got Param=%q Params=%v", valErr.Param, valErr.Params)
}
return valErr
}
func TestParseFileFlag(t *testing.T) {
tests := []struct {
name string
raw string
defaultField string
wantField string
wantPath string
wantStdin bool
}{
{
name: "simple filename uses default field",
raw: "photo.jpg",
defaultField: "file",
wantField: "file",
wantPath: "photo.jpg",
wantStdin: false,
},
{
name: "simple filename with custom default",
raw: "photo.jpg",
defaultField: "image",
wantField: "image",
wantPath: "photo.jpg",
wantStdin: false,
},
{
name: "explicit field prefix",
raw: "image=photo.jpg",
defaultField: "file",
wantField: "image",
wantPath: "photo.jpg",
wantStdin: false,
},
{
name: "stdin bare",
raw: "-",
defaultField: "file",
wantField: "file",
wantPath: "",
wantStdin: true,
},
{
name: "stdin with field prefix",
raw: "image=-",
defaultField: "file",
wantField: "image",
wantPath: "",
wantStdin: true,
},
{
name: "path with equals sign (only first equals splits)",
raw: "field=path/to/file=1.jpg",
defaultField: "file",
wantField: "field",
wantPath: "path/to/file=1.jpg",
wantStdin: false,
},
{
name: "absolute path no prefix",
raw: "/tmp/photo.jpg",
defaultField: "file",
wantField: "file",
wantPath: "/tmp/photo.jpg",
wantStdin: false,
},
{
name: "absolute path with field prefix",
raw: "image=/tmp/photo.jpg",
defaultField: "file",
wantField: "image",
wantPath: "/tmp/photo.jpg",
wantStdin: false,
},
{
name: "empty field prefix falls through to default",
raw: "=photo.jpg",
defaultField: "file",
wantField: "file",
wantPath: "=photo.jpg",
wantStdin: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
field, path, isStdin := ParseFileFlag(tt.raw, tt.defaultField)
if field != tt.wantField {
t.Errorf("field = %q, want %q", field, tt.wantField)
}
if path != tt.wantPath {
t.Errorf("path = %q, want %q", path, tt.wantPath)
}
if isStdin != tt.wantStdin {
t.Errorf("isStdin = %v, want %v", isStdin, tt.wantStdin)
}
})
}
}
func TestValidateFileFlag(t *testing.T) {
tests := []struct {
name string
file string
params string
data string
outputPath string
pageAll bool
httpMethod string
wantErr string // empty means no error
}{
{
name: "empty file is valid",
file: "",
httpMethod: "GET",
wantErr: "",
},
{
name: "empty file path",
file: "field=",
httpMethod: "POST",
wantErr: "--file: empty file path",
},
{
name: "file with output",
file: "photo.jpg",
outputPath: "out.json",
httpMethod: "POST",
wantErr: "--file and --output are mutually exclusive",
},
{
name: "file with page-all",
file: "photo.jpg",
pageAll: true,
httpMethod: "POST",
wantErr: "--file and --page-all are mutually exclusive",
},
{
name: "stdin file with stdin data",
file: "-",
data: "-",
httpMethod: "POST",
wantErr: "--file and --data cannot both read from stdin",
},
{
name: "stdin file with stdin params",
file: "-",
params: "-",
httpMethod: "POST",
wantErr: "--file and --params cannot both read from stdin",
},
{
name: "file with GET method",
file: "photo.jpg",
httpMethod: "GET",
wantErr: "--file requires POST, PUT, PATCH, or DELETE method",
},
{
name: "file with POST method",
file: "photo.jpg",
httpMethod: "POST",
wantErr: "",
},
{
name: "file with PUT method",
file: "photo.jpg",
httpMethod: "PUT",
wantErr: "",
},
{
name: "file with PATCH method",
file: "photo.jpg",
httpMethod: "PATCH",
wantErr: "",
},
{
name: "file with DELETE method",
file: "photo.jpg",
httpMethod: "DELETE",
wantErr: "",
},
{
name: "stdin with field prefix and data stdin",
file: "image=-",
data: "-",
httpMethod: "POST",
wantErr: "--file and --data cannot both read from stdin",
},
{
name: "stdin with field prefix and params stdin",
file: "image=-",
params: "-",
httpMethod: "POST",
wantErr: "--file and --params cannot both read from stdin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFileFlag(tt.file, tt.params, tt.data, tt.outputPath, tt.pageAll, tt.httpMethod)
if tt.wantErr == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
})
}
}
func TestBuildFormdata(t *testing.T) {
fio := &localfileio.LocalFileIO{}
t.Run("stdin success", func(t *testing.T) {
stdin := bytes.NewReader([]byte("file-content-here"))
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("stdin nil reader", func(t *testing.T) {
_, err := BuildFormdata(fio, "file", "", true, nil, nil)
if err == nil {
t.Fatal("expected error for nil stdin")
}
if !strings.Contains(err.Error(), "stdin is not available") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available")
}
requireFileValidationError(t, err, errs.SubtypeFailedPrecondition)
})
t.Run("stdin read failure", func(t *testing.T) {
readErr := errors.New("pipe closed")
_, err := BuildFormdata(fio, "file", "", true, &failingReader{err: readErr}, nil)
if err == nil {
t.Fatal("expected error for failing stdin reader")
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
if !errors.Is(err, readErr) {
t.Error("underlying read error not reachable via errors.Is; WithCause missing")
}
})
t.Run("stdin empty", func(t *testing.T) {
stdin := bytes.NewReader([]byte{})
_, err := BuildFormdata(fio, "file", "", true, stdin, nil)
if err == nil {
t.Fatal("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty")
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
})
t.Run("file open success", func(t *testing.T) {
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
fd, err := BuildFormdata(fio, "photo", "test.txt", false, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("file not found", func(t *testing.T) {
dir := t.TempDir()
TestChdir(t, dir)
_, err := BuildFormdata(fio, "file", "nonexistent.txt", false, nil, nil)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "cannot open file:") {
t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:")
}
valErr := requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
if valErr.Cause == nil {
t.Error("expected the os open error attached as Cause")
}
})
t.Run("dataJSON fields added", func(t *testing.T) {
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
dataJSON := map[string]any{
"file_name": "report.pdf",
"parent_type": "doc_image",
"size": 1024,
}
fd, err := BuildFormdata(fio, "file", "upload.bin", false, nil, dataJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("dataJSON nil is fine", func(t *testing.T) {
stdin := bytes.NewReader([]byte("content"))
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("dataJSON non-map is ignored", func(t *testing.T) {
stdin := bytes.NewReader([]byte("content"))
fd, err := BuildFormdata(fio, "file", "", true, stdin, "not-a-map")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
}
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
// float64 delegates to %g which switches to scientific notation at ~1e6
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
// integer reject that, surfacing as a generic "params error".
func TestFormatFormFieldValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in any
want string
}{
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
{"float64 below scientific threshold", float64(358934), "358934"},
{"float64 zero", float64(0), "0"},
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
{"float64 negative", float64(-42), "-42"},
{"float64 fractional preserved", float64(3.14), "3.14"},
{"string pass-through", "hello", "hello"},
{"bool true", true, "true"},
{"int via %v", 42, "42"},
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatFormFieldValue(tt.in)
if got != tt.want {
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
// command — one kept alive for users whose skill predates a refactor. Service
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
// rendering and unknown-subcommand suggestions read it to separate these
// aliases from the current commands.
const DeprecatedGroupID = "deprecated"
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
func IsDeprecatedCommand(c *cobra.Command) bool {
return c != nil && c.GroupID == DeprecatedGroupID
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"github.com/larksuite/cli/internal/core"
)
// PrintIdentity outputs the current identity to stderr so callers (including AI agents)
// can see which identity is being used for the API call.
func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) {
if as.IsBot() {
if autoDetected {
fmt.Fprintln(w, "[identity: bot (auto — not logged in; `auth login` for user identity)]")
} else {
fmt.Fprintln(w, "[identity: bot]")
}
} else if config != nil && config.UserOpenId != "" {
fmt.Fprintf(w, "[identity: user (%s)]\n", config.UserOpenId)
} else {
fmt.Fprintln(w, "[identity: user]")
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
)
// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands.
func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) {
addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{
defaultValue: "",
usage: "identity type: user | bot",
completionValues: []string{"user", "bot"},
})
}
// AddShortcutIdentityFlag registers the standard --as flag shape used by shortcuts.
func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, authTypes []string) {
if len(authTypes) == 0 {
authTypes = []string{"user"}
}
addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{
defaultValue: "",
usage: "identity type: " + strings.Join(authTypes, " | "),
completionValues: authTypes,
})
}
type identityFlagConfig struct {
defaultValue string
usage string
completionValues []string
}
// addIdentityFlag centralizes --as registration and strict-mode UX.
// When strict mode is active, the flag is still accepted for compatibility
// but hidden from help/completion and locked to the forced identity by default.
func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) {
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
// Keep registering --as in strict mode even though it is hidden.
// This preserves parser compatibility for existing invocations that still pass
// --as, and keeps downstream GetString("as") / ResolveAs paths stable.
// The usage text below is effectively placeholder text because the flag is hidden.
registerIdentityFlag(cmd, target, string(forced),
fmt.Sprintf("identity locked to %s by strict mode (admin-managed)", forced))
_ = cmd.Flags().MarkHidden("as")
return
}
registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage)
RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp
})
}
func registerIdentityFlag(cmd *cobra.Command, target *string, defaultValue, usage string) {
if target != nil {
cmd.Flags().StringVar(target, "as", defaultValue, usage)
return
}
cmd.Flags().String("as", defaultValue, usage)
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddAPIIdentityFlag(context.Background(), cmd, f, 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 visible outside strict mode")
}
if got := flag.DefValue; got != "" {
t.Fatalf("default value = %q, want empty string", got)
}
}
func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s", SupportedIdentities: 2,
})
cmd := &cobra.Command{Use: "test"}
AddAPIIdentityFlag(context.Background(), cmd, f, 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")
}
}
func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"})
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 visible outside strict mode")
}
if got := flag.DefValue; got != "" {
t.Fatalf("default value = %q, want empty string", got)
}
}
// TC-10: AuthTypes=["user"] → usage contains "identity type: user" and NOT "bot".
func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"})
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 visible")
}
wantUsage := "identity type: user"
if flag.Usage != wantUsage {
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
}
if strings.Contains(flag.Usage, "bot") {
t.Errorf("Usage should not contain \"bot\" for user-only shortcut, got %q", flag.Usage)
}
}
// TC-11: AuthTypes=["user","bot"] → usage == "identity type: user | bot".
func TestAddShortcutIdentityFlag_UserBotAuthTypes(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"})
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 visible")
}
if got := flag.DefValue; got != "" {
t.Fatalf("default value = %q, want empty string", got)
}
wantUsage := "identity type: user | bot"
if flag.Usage != wantUsage {
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
}
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestPrintIdentity_BotExplicit(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsBot, nil, false)
if !strings.Contains(buf.String(), "[identity: bot]") {
t.Errorf("unexpected output: %s", buf.String())
}
}
func TestPrintIdentity_BotAutoDetected(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsBot, nil, true)
if !strings.Contains(buf.String(), "auto") {
t.Errorf("expected auto hint, got: %s", buf.String())
}
}
func TestPrintIdentity_UserWithOpenId(t *testing.T) {
var buf bytes.Buffer
cfg := &core.CliConfig{UserOpenId: "ou_abc123"}
PrintIdentity(&buf, core.AsUser, cfg, false)
if !strings.Contains(buf.String(), "ou_abc123") {
t.Errorf("expected UserOpenId in output, got: %s", buf.String())
}
}
func TestPrintIdentity_UserWithoutOpenId(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsUser, &core.CliConfig{}, false)
if !strings.Contains(buf.String(), "[identity: user]") {
t.Errorf("unexpected output: %s", buf.String())
}
}
func TestPrintIdentity_UserNilConfig(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsUser, nil, false)
if !strings.Contains(buf.String(), "[identity: user]") {
t.Errorf("unexpected output: %s", buf.String())
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"os"
"golang.org/x/term"
)
// IOStreams provides the standard input/output/error streams.
// Commands should use these instead of os.Stdin/Stdout/Stderr
// to enable testing and output capture.
type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
// Computed once in NewIOStreams, mirroring IsTerminal; tests assign it
// directly like cmd/config/bind_test.go does for IsTerminal.
StderrIsTerminal bool
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
//
//nolint:forbidigo // entry point for real stdio
func SystemIO() *IOStreams {
return NewIOStreams(os.Stdin, os.Stdout, os.Stderr)
}
// normalizeStreams returns a fresh IOStreams with any nil field filled from
// SystemIO(). Callers constructing a partial struct like &IOStreams{Out: buf}
// get a usable result without nil writers leaking into RoundTripper warnings,
// Cobra I/O, or credential-provider error paths.
func normalizeStreams(s *IOStreams) *IOStreams {
if s == nil {
return SystemIO()
}
out := *s
if out.In == nil || out.Out == nil || out.ErrOut == nil {
sys := SystemIO()
if out.In == nil {
out.In = sys.In
}
if out.Out == nil {
out.Out = sys.Out
}
if out.ErrOut == nil {
out.ErrOut = sys.ErrOut
}
}
return &out
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data: %s", err).
WithParam("--data").
WithCause(err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON format").
WithParam("--data").
WithCause(err)
}
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty (never nil) map
// for empty input or the JSON literal null, so callers can always overlay onto
// the result without a nil-map panic.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", label, err).
WithParam(label).
WithCause(err)
}
if resolved == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s invalid format, expected JSON object", label).
WithParam(label).
WithCause(err)
}
if result == nil {
// `null` unmarshals into a nil map without error; normalize it so the
// returned map is always writable, matching the empty-input case.
return map[string]any{}, nil
}
return result, nil
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// requireJSONInputValidationError asserts err is a typed *errs.ValidationError
// with subtype invalid_argument, exit code 2 (legacy ErrValidation parity),
// and the offending flag recorded as Param.
func requireJSONInputValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != wantParam {
t.Errorf("param = %q, want %q", valErr.Param, wantParam)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation)
}
if valErr.Cause == nil {
t.Error("expected the underlying parse/resolve error attached as Cause")
}
}
func TestParseOptionalBody(t *testing.T) {
fio := &localfileio.LocalFileIO{}
tests := []struct {
name string
method string
data string
wantNil bool
wantErr bool
}{
{"GET ignored", "GET", `{"a":1}`, true, false},
{"POST empty data", "POST", "", true, false},
{"POST valid", "POST", `{"key":"val"}`, false, false},
{"PUT valid", "PUT", `[1,2,3]`, false, false},
{"PATCH valid", "PATCH", `"hello"`, false, false},
{"DELETE valid", "DELETE", `{"id":"1"}`, false, false},
{"POST invalid json", "POST", `{bad}`, true, true},
{"POST unreadable @file", "POST", "@/nonexistent/body.json", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil, fio)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
requireJSONInputValidationError(t, err, "--data")
return
}
if tt.wantNil && got != nil {
t.Errorf("ParseOptionalBody() = %v, want nil", got)
}
if !tt.wantNil && got == nil {
t.Error("ParseOptionalBody() = nil, want non-nil")
}
})
}
}
func TestParseJSONMap(t *testing.T) {
fio := &localfileio.LocalFileIO{}
tests := []struct {
name string
input string
label string
wantLen int
wantErr bool
}{
{"empty input", "", "--params", 0, false},
{"json null", "null", "--params", 0, false},
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
{"invalid json", `{bad}`, "--params", 0, true},
{"json array", `[1,2]`, "--data", 0, true},
{"unreadable @file", "@/nonexistent/params.json", "--params", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil, fio)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
requireJSONInputValidationError(t, err, tt.label)
return
}
if len(got) != tt.wantLen {
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
}
// A successful parse must yield a non-nil, writable map: callers
// overlay onto it (params[k]=v), so `null` — which unmarshals to a
// nil map without error — must normalize to {} like empty input.
if !tt.wantErr && got == nil {
t.Error("ParseJSONMap() = nil map on success, want non-nil")
}
})
}
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/i18n"
)
// ParseLangFlag validates and canonicalizes a --lang value, shared by config
// and profile so every entry point honors one contract. Empty is unset (no-op);
// a non-empty value must resolve via i18n.Parse or it errors.
func ParseLangFlag(raw string) (i18n.Lang, error) {
if raw == "" {
return "", nil
}
lang, ok := i18n.Parse(raw)
if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", ")).
WithParam("--lang")
}
return lang, nil
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "@<path>" → read all bytes from the file at <path> via fileIO
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// fileIO is required for "@<path>" inputs and goes through path validation
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
//
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
// by reading JSON from a file (@path) or piping via stdin (-).
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (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
}
// escape: @@... → literal @... (no file read)
if strings.HasPrefix(raw, "@@") {
return raw[1:], nil
}
// file: @path
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(raw[1:])
if path == "" {
return "", fmt.Errorf("file path cannot be empty after @")
}
data, err := ReadInputFile(fileIO, path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("file %q is empty", path)
}
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
}
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}
f, err := fileIO.Open(path)
if err != nil {
return nil, wrapInputFileError(path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
func wrapInputFileError(path string, err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("invalid file path %q: %w", path, err)
}
return fmt.Errorf("cannot read file %q: %w", path, err)
}
+314
View File
@@ -0,0 +1,314 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
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"), nil)
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(""), nil)
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{}, nil)
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 "), nil)
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil, 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, 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, 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, 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_AtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@params.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"folder_token":"abc123"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@p.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_NotFound(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
_, err := ResolveInput("@missing.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
// Absolute paths are rejected by SafeInputPath; the error must surface
// as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@/etc/passwd", nil, fio)
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected path-validation error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
fio := &localfileio.LocalFileIO{}
_, err := ResolveInput("@", nil, fio)
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("expected empty-path error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := ResolveInput("@empty.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Errorf("expected empty-file error, got: %v", err)
}
}
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
// When fileIO is nil, @path must error rather than silently fall back.
_, err := ResolveInput("@params.json", nil, nil)
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Errorf("expected unavailable error, got: %v", err)
}
}
func TestResolveInput_DoubleAtEscape(t *testing.T) {
got, err := ResolveInput("@@literal", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@literal" {
t.Errorf("got %q, want %q", got, "@literal")
}
}
// 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, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
}
// Integration: @file flows through ParseJSONMap correctly.
func TestParseJSONMap_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
if got["folder_token"] != "abc123" {
t.Errorf("got %v, want folder_token=abc123", got)
}
}
func TestParseOptionalBody_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
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)
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, 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, nil)
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, 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)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
const riskLevelAnnotationKey = "risk_level"
// Risk level constants — aliases of the canonical core.Risk* values, re-exported
// here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers
// from one package. core is the single source of truth.
const (
RiskRead = core.RiskRead
RiskWrite = core.RiskWrite
RiskHighRiskWrite = core.RiskHighRiskWrite
)
// SetRisk stores a command's static risk level on cobra annotations so the
// help renderer (cmd/root.go) can surface a Risk: line without importing
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
// acts on RiskHighRiskWrite.
func SetRisk(cmd *cobra.Command, level string) {
if level == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[riskLevelAnnotationKey] = level
}
// GetRisk returns the static risk level. ok is true when the command has a
// risk annotation.
func GetRisk(cmd *cobra.Command) (level string, ok bool) {
if cmd.Annotations == nil {
return "", false
}
level, ok = cmd.Annotations[riskLevelAnnotationKey]
return level, ok && level != ""
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestSetRisk_EmptyLevelShortCircuits(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetRisk(cmd, "")
if cmd.Annotations != nil {
t.Errorf("expected annotations untouched for empty level, got %v", cmd.Annotations)
}
}
func TestSetRisk_PopulatesLevel(t *testing.T) {
cases := []string{"read", "write", "high-risk-write"}
for _, level := range cases {
t.Run(level, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetRisk(cmd, level)
got, ok := GetRisk(cmd)
if !ok {
t.Fatal("expected ok=true after SetRisk")
}
if got != level {
t.Errorf("level = %q, want %q", got, level)
}
})
}
}
func TestSetRisk_PreservesExistingAnnotations(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{"other": "val"},
}
SetRisk(cmd, "high-risk-write")
if cmd.Annotations["other"] != "val" {
t.Error("existing annotation should be preserved")
}
if level, ok := GetRisk(cmd); !ok || level != "high-risk-write" {
t.Errorf("risk not written: level=%q ok=%v", level, ok)
}
}
func TestSetRisk_InitializesNilAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if cmd.Annotations != nil {
t.Fatal("precondition: Annotations should be nil on a fresh command")
}
SetRisk(cmd, "write")
if cmd.Annotations == nil {
t.Fatal("SetRisk should lazily initialize Annotations")
}
}
func TestGetRisk_NilAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
level, ok := GetRisk(cmd)
if ok {
t.Error("expected ok=false for nil Annotations")
}
if level != "" {
t.Errorf("expected empty level, got %q", level)
}
}
func TestGetRisk_NoRiskKey(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{"unrelated": "x"},
}
if _, ok := GetRisk(cmd); ok {
t.Error("expected ok=false when risk key is absent")
}
}
func TestGetRisk_EmptyValueReturnsNotOK(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{riskLevelAnnotationKey: ""},
}
level, ok := GetRisk(cmd)
if ok {
t.Error("expected ok=false for empty level value")
}
if level != "" {
t.Errorf("expected empty level, got %q", level)
}
}
+210
View File
@@ -0,0 +1,210 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"net/http"
"reflect"
"runtime/debug"
"strings"
"sync"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/envvars"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
HeaderSource = "X-Cli-Source"
HeaderVersion = "X-Cli-Version"
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
SourceValue = "lark-cli"
HeaderUserAgent = "User-Agent"
// BuildKindOfficial / BuildKindExtended / BuildKindUnknown are the values
// reported in the X-Cli-Build header; see DetectBuildKind for semantics.
BuildKindOfficial = "official"
BuildKindExtended = "extended"
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
h.Set(HeaderSource, SourceValue)
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h
}
var (
buildKindOnce sync.Once
buildKindVal string
)
// DetectBuildKind reports whether this binary is the official CLI, an
// extended/repackaged build, or unknown. The result is cached via sync.Once
// so it is computed only on the first call.
//
// IMPORTANT: must NOT be called from any package init(). Go's init ordering
// follows the import graph; ISV providers registered via blank import may not
// have run yet, which would misclassify an extended build as official. Call
// only when handling an actual request (e.g. from BaseSecurityHeaders).
func DetectBuildKind() string {
buildKindOnce.Do(func() {
buildKindVal = computeBuildKind()
})
return buildKindVal
}
// computeBuildKind performs the actual detection without any caching.
// Exposed for tests. Gathers runtime/global inputs and delegates the pure
// branching logic to classifyBuild so that logic can be unit-tested without
// mutating process-wide provider registries.
func computeBuildKind() string {
info, ok := debug.ReadBuildInfo()
mainPath := ""
if ok {
mainPath = info.Main.Path
}
credProviders := credential.Providers()
creds := make([]any, len(credProviders))
for i, p := range credProviders {
creds[i] = p
}
var tp any
if p := exttransport.GetProvider(); p != nil {
tp = p
}
var fp any
if p := fileio.GetProvider(); p != nil {
fp = p
}
return classifyBuild(mainPath, ok, creds, tp, fp)
}
// classifyBuild is the pure classification logic used by computeBuildKind.
// Callers supply concrete values so every branch is reachable from tests
// without touching debug.ReadBuildInfo or the extension registries.
//
// Priority order mirrors the design doc:
// 1. no build info → unknown
// 2. main module path not the official one → extended (ISV wrapper)
// 3. any non-builtin provider (credential / transport / fileio) → extended
// 4. otherwise → official
func classifyBuild(mainPath string, haveBuildInfo bool, credProviders []any, transportProvider, fileioProvider any) string {
if !haveBuildInfo {
return BuildKindUnknown
}
if mainPath != "" && mainPath != officialModulePath {
return BuildKindExtended
}
for _, p := range credProviders {
if !isBuiltinProvider(p) {
return BuildKindExtended
}
}
if transportProvider != nil && !isBuiltinProvider(transportProvider) {
return BuildKindExtended
}
if fileioProvider != nil && !isBuiltinProvider(fileioProvider) {
return BuildKindExtended
}
return BuildKindOfficial
}
// isBuiltinProvider reports whether p is declared under the official module
// path. Third-party providers live under their own module and fail this check.
// Using reflect.PkgPath makes this robust against Name() spoofing since
// package paths are fixed at compile time.
func isBuiltinProvider(p any) bool {
if p == nil {
return false
}
t := reflect.TypeOf(p)
if t == nil {
return false
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
pkg := t.PkgPath()
return pkg == officialModulePath || strings.HasPrefix(pkg, officialModulePath+"/")
}
// ── Context utilities ──
type ctxKey string
const (
ctxShortcutName ctxKey = "lark:shortcut-name"
ctxExecutionId ctxKey = "lark:execution-id"
)
// ContextWithShortcut injects shortcut name and execution ID into the context.
func ContextWithShortcut(ctx context.Context, name, executionId string) context.Context {
ctx = context.WithValue(ctx, ctxShortcutName, name)
ctx = context.WithValue(ctx, ctxExecutionId, executionId)
return ctx
}
// ShortcutNameFromContext extracts the shortcut name from the context.
func ShortcutNameFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxShortcutName).(string)
return v, ok && v != ""
}
// ExecutionIdFromContext extracts the execution ID from the context.
func ExecutionIdFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxExecutionId).(string)
return v, ok && v != ""
}
// ShortcutHeaderOpts extracts Shortcut info from the context and returns a
// RequestOptionFunc that injects the corresponding headers into SDK requests.
// Returns nil if the context has no Shortcut info.
func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
h := ShortcutHeaders(ctx)
if h == nil {
return nil
}
return larkcore.WithHeaders(h)
}
// ShortcutHeaders extracts Shortcut info from the context and returns
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
func ShortcutHeaders(ctx context.Context) http.Header {
name, ok := ShortcutNameFromContext(ctx)
if !ok {
return nil
}
h := make(http.Header)
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
}
return h
}
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package cmdutil
import (
"testing"
sidecarcred "github.com/larksuite/cli/extension/credential/sidecar"
sidecartrans "github.com/larksuite/cli/extension/transport/sidecar"
)
// TestIsBuiltinProvider_SidecarProviders locks the classification for the
// sidecar-mode providers enumerated in design doc §3.3.2 as "官方自带". These
// types only compile when the `authsidecar` build tag is active, so the test
// is guarded by the same tag.
func TestIsBuiltinProvider_SidecarProviders(t *testing.T) {
cases := []struct {
name string
provider any
}{
{"sidecar credential provider", &sidecarcred.Provider{}},
{"sidecar transport provider", &sidecartrans.Provider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !isBuiltinProvider(tc.provider) {
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
}
})
}
}
+315
View File
@@ -0,0 +1,315 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"net/http"
"testing"
"github.com/larksuite/cli/extension/credential"
envcred "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// ---------------------------------------------------------------------------
// isBuiltinProvider
// ---------------------------------------------------------------------------
// cmdutilLocalProvider has PkgPath under the official module
// ("github.com/larksuite/cli/internal/cmdutil") and should be classified
// as builtin.
type cmdutilLocalProvider struct{}
// Name intentionally returns a value that mimics an external provider; the
// PkgPath-based classifier must ignore it. See TestIsBuiltinProvider_PkgPathNotSpoofableByName.
func (cmdutilLocalProvider) Name() string { return "external-spoofed-provider" }
func (cmdutilLocalProvider) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, nil
}
func (cmdutilLocalProvider) ResolveToken(context.Context, credential.TokenSpec) (*credential.Token, error) {
return nil, nil
}
func TestIsBuiltinProvider_Nil(t *testing.T) {
if isBuiltinProvider(nil) {
t.Fatal("isBuiltinProvider(nil) = true, want false")
}
}
func TestIsBuiltinProvider_TypeUnderOfficialModule(t *testing.T) {
if !isBuiltinProvider(&cmdutilLocalProvider{}) {
t.Fatal("type under github.com/larksuite/cli/... should be builtin")
}
}
func TestIsBuiltinProvider_StdlibTypeIsNotBuiltin(t *testing.T) {
// A standard library type has PkgPath "net/http" — outside official module.
// This covers the non-builtin branch, which we cannot trigger from inside
// this test file using a locally-defined type.
if isBuiltinProvider(&http.Server{}) {
t.Fatal("stdlib type classified as builtin, PkgPath check is broken")
}
}
func TestIsBuiltinProvider_PkgPathNotSpoofableByName(t *testing.T) {
// Name() returns a string, but classification uses reflect.Type.PkgPath
// which is compile-time fixed. The local type returns a name that looks
// like an ISV provider; it must still classify as builtin.
p := &cmdutilLocalProvider{}
if p.Name() != "external-spoofed-provider" {
t.Fatalf("sanity check: Name() = %q, spoof value lost", p.Name())
}
if !isBuiltinProvider(p) {
t.Fatal("isBuiltinProvider should decide by PkgPath, not Name()")
}
}
// TestIsBuiltinProvider_NonPointerValues covers the non-pointer reflect branch.
// The existing tests only exercise pointer receivers (&T{}); when a provider
// is passed by value the reflect.Kind is not Ptr and t.Elem() is skipped.
func TestIsBuiltinProvider_NonPointerValues(t *testing.T) {
if !isBuiltinProvider(cmdutilLocalProvider{}) {
t.Fatal("non-pointer local type should be builtin (PkgPath still under official module)")
}
// http.Server as a non-pointer — PkgPath "net/http", not under official.
if isBuiltinProvider(http.Server{}) {
t.Fatal("non-pointer stdlib type should not be builtin")
}
}
// TestIsBuiltinProvider_RealBuiltinProviders locks down the classification
// for the concrete providers enumerated in design doc §3.3.2 as "官方自带":
// env credential provider and local fileio provider. If any of these is
// moved out of the official module tree in the future, this test must flip
// red so the new package path is explicitly considered.
//
// The sidecar providers (extension/credential/sidecar and
// extension/transport/sidecar) are guarded by the `authsidecar` build tag
// and covered in secheader_sidecar_test.go under that tag.
func TestIsBuiltinProvider_RealBuiltinProviders(t *testing.T) {
cases := []struct {
name string
provider any
}{
{"env credential provider", &envcred.Provider{}},
{"local fileio provider", &localfileio.Provider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !isBuiltinProvider(tc.provider) {
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
}
})
}
}
// ---------------------------------------------------------------------------
// computeBuildKind
// ---------------------------------------------------------------------------
func TestComputeBuildKind_ReturnsKnownValue(t *testing.T) {
// Under `go test`, Main.Path is typically the module being tested
// ("github.com/larksuite/cli"); the concrete return may still be
// official, extended, or unknown depending on Main.Path and the
// registered providers. Just assert it's one of the defined values.
got := computeBuildKind()
switch got {
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
default:
t.Fatalf("computeBuildKind() = %q, want one of official/extended/unknown", got)
}
}
// ---------------------------------------------------------------------------
// classifyBuild — pure branching logic
// ---------------------------------------------------------------------------
//
// These tests cover every branch of classifyBuild with explicit inputs,
// which is impossible from computeBuildKind alone because debug.ReadBuildInfo
// and the process-wide provider registries can't be reshaped in a test.
func TestClassifyBuild_NoBuildInfo_ReturnsUnknown(t *testing.T) {
if got := classifyBuild("", false, nil, nil, nil); got != BuildKindUnknown {
t.Fatalf("classifyBuild(haveBuildInfo=false) = %q, want %q", got, BuildKindUnknown)
}
}
func TestClassifyBuild_ExtendedMainPath_ReturnsExtended(t *testing.T) {
cases := []string{
"github.com/acme/lark-cli-wrapper",
"example.com/isv/lark",
"gitlab.mycorp.internal/tools/lark-cli-fork",
}
for _, mp := range cases {
t.Run(mp, func(t *testing.T) {
if got := classifyBuild(mp, true, nil, nil, nil); got != BuildKindExtended {
t.Fatalf("mainPath=%q classifyBuild = %q, want %q", mp, got, BuildKindExtended)
}
})
}
}
func TestClassifyBuild_OfficialMainPath_NoProviders_ReturnsOfficial(t *testing.T) {
if got := classifyBuild(officialModulePath, true, nil, nil, nil); got != BuildKindOfficial {
t.Fatalf("classifyBuild(official, no providers) = %q, want %q", got, BuildKindOfficial)
}
}
func TestClassifyBuild_EmptyMainPath_DoesNotTriggerExtended(t *testing.T) {
// An empty Main.Path (rare, e.g. `go run` pre-1.18) must not be treated
// as extended by itself — the classifier falls through to provider checks.
if got := classifyBuild("", true, nil, nil, nil); got != BuildKindOfficial {
t.Fatalf("classifyBuild(empty mainPath, no providers) = %q, want %q", got, BuildKindOfficial)
}
}
func TestClassifyBuild_NonBuiltinCredentialProvider_ReturnsExtended(t *testing.T) {
// Any non-builtin credential provider flips the verdict to extended.
got := classifyBuild(officialModulePath, true, []any{&http.Server{}}, nil, nil)
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external credential = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_MixedCredentialProviders_ExtendedWins(t *testing.T) {
// Even if most providers are builtin, a single external one decides.
providers := []any{&cmdutilLocalProvider{}, &http.Server{}}
if got := classifyBuild(officialModulePath, true, providers, nil, nil); got != BuildKindExtended {
t.Fatalf("classifyBuild mixed providers = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_NonBuiltinTransportProvider_ReturnsExtended(t *testing.T) {
got := classifyBuild(officialModulePath, true, nil, &http.Server{}, nil)
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external transport = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_NonBuiltinFileioProvider_ReturnsExtended(t *testing.T) {
got := classifyBuild(officialModulePath, true, nil, nil, &http.Server{})
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external fileio = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_AllBuiltinProviders_ReturnsOfficial(t *testing.T) {
// All three slots filled with builtin providers must still classify as official.
got := classifyBuild(
officialModulePath, true,
[]any{&cmdutilLocalProvider{}},
&cmdutilLocalProvider{},
&cmdutilLocalProvider{},
)
if got != BuildKindOfficial {
t.Fatalf("classifyBuild all-builtin = %q, want %q", got, BuildKindOfficial)
}
}
// TestClassifyBuild_MainPathPriorityOverProviders documents that the main
// module path takes precedence: even with only builtin providers, a non-
// official main path still yields extended.
func TestClassifyBuild_MainPathPriorityOverProviders(t *testing.T) {
got := classifyBuild(
"github.com/acme/lark-wrapper", true,
[]any{&cmdutilLocalProvider{}},
&cmdutilLocalProvider{},
&cmdutilLocalProvider{},
)
if got != BuildKindExtended {
t.Fatalf("main-path override failed: got %q, want %q", got, BuildKindExtended)
}
}
// ---------------------------------------------------------------------------
// DetectBuildKind — sync.Once caching
// ---------------------------------------------------------------------------
func TestDetectBuildKind_StableAcrossCalls(t *testing.T) {
a := DetectBuildKind()
b := DetectBuildKind()
if a != b {
t.Fatalf("DetectBuildKind() returned different values on repeat: %q vs %q", a, b)
}
}
// ---------------------------------------------------------------------------
// BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
h := BaseSecurityHeaders()
v := h.Get(HeaderBuild)
if v == "" {
t.Fatal("BaseSecurityHeaders missing X-Cli-Build header")
}
switch v {
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
default:
t.Fatalf("X-Cli-Build = %q, want one of official/extended/unknown", v)
}
}
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
h := BaseSecurityHeaders()
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
if h.Get(key) == "" {
t.Errorf("BaseSecurityHeaders missing %s", key)
}
}
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentTraceHeaderWhenEnvSet(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-xyz-789")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-xyz-789" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentTrace, v, "trace-xyz-789")
}
}
func TestBaseSecurityHeaders_AgentTraceTrimmedWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-trim" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q (whitespace trimmed)", HeaderAgentTrace, v, "trace-trim")
}
}
func TestBaseSecurityHeaders_AgentTraceOnlyWhitespace_Skipped(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for whitespace-only value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsCRLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for CR/LF value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for LF value", HeaderAgentTrace, v)
}
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"context"
"net/http"
"os"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
type noopKeychain struct{}
func (n *noopKeychain) Get(service, account string) (string, error) { return "", nil }
func (n *noopKeychain) Set(service, account, value string) error { return nil }
func (n *noopKeychain) Remove(service, account string) error { return nil }
// TestFactory creates a Factory for testing.
// Returns (factory, stdout buffer, stderr buffer, http mock registry).
func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stdoutBuf := &bytes.Buffer{}
stderrBuf := &bytes.Buffer{}
mockClient := httpmock.NewClient(reg)
sdkMockClient := &http.Client{
Transport: &UserAgentTransport{Base: reg},
}
var testLarkClient *lark.Client
if config != nil && config.AppID != "" {
opts := []lark.ClientOptionFunc{
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(sdkMockClient),
lark.WithHeaders(BaseSecurityHeaders()),
}
if config.Brand != "" {
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand)))
}
testLarkClient = lark.NewClient(config.AppID, credential.RuntimeAppSecret(config.AppSecret), opts...)
}
testCred := credential.NewCredentialProvider(
nil,
&testDefaultAcct{config: config},
&testDefaultToken{},
func() (*http.Client, error) { return mockClient, nil },
)
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
Keychain: &noopKeychain{},
Credential: testCred,
FileIOProvider: fileio.GetProvider(),
}
return f, stdoutBuf, stderrBuf, reg
}
type testDefaultAcct struct {
config *core.CliConfig
}
func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Account, error) {
if a.config == nil {
return &credential.Account{}, nil
}
return credential.AccountFromCliConfig(a.config), nil
}
// TestChdir changes the working directory to dir for the duration of the test.
// The original directory is restored via t.Cleanup.
// This enables tests to use LocalFileIO (which resolves relative paths under cwd)
// with temporary directories, keeping test artifacts out of the source tree.
// Not compatible with t.Parallel() — os.Chdir is process-wide.
func TestChdir(t *testing.T, dir string) {
t.Helper()
orig, err := vfs.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir
t.Fatalf("Chdir(%s): %v", dir, err)
}
t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore
}
type testDefaultToken struct{}
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestTestFactory_ReplacesGlobals(t *testing.T) {
config := &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, stdout, stderr, reg := TestFactory(t, config)
// Factory should return our config
got, err := f.Config()
if err != nil {
t.Fatalf("Config() error: %v", err)
}
if got.AppID != "test-app" {
t.Errorf("want AppID test-app, got %s", got.AppID)
}
// IOStreams.Out/ErrOut should be our buffers
output.PrintJson(f.IOStreams.Out, map[string]string{"key": "value"})
if !strings.Contains(stdout.String(), `"key"`) {
t.Error("output.PrintJson did not write to test stdout")
}
output.PrintError(f.IOStreams.ErrOut, "test error")
if !strings.Contains(stderr.String(), "test error") {
t.Error("output.PrintError did not write to test stderr")
}
// Register a stub so Verify passes
reg.Register(&httpmock.Stub{
URL: "/test",
Body: "ok",
})
// Use the stub via Factory HttpClient
httpClient, err := f.HttpClient()
if err != nil {
t.Fatalf("HttpClient() error: %v", err)
}
baseURL := core.ResolveOpenBaseURL(core.BrandFeishu)
req, _ := http.NewRequest("GET", baseURL+"/test", nil)
resp, err := httpClient.Do(req)
if err != nil {
t.Fatalf("HttpClient request error: %v", err)
}
resp.Body.Close()
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// ThemeFeishu returns a huh theme with Feishu brand colors.
func ThemeFeishu() *huh.Theme {
t := huh.ThemeBase()
var (
blue = lipgloss.Color("#1456F0") // 标题、边框
teal = lipgloss.Color("#33D6C0") // 选择器、光标、输入提示
cyan = lipgloss.Color("#3EC3C0") // 选中项
orange = lipgloss.Color("#FF811A") // 按钮高亮
magenta = lipgloss.Color("#CC398C") // 错误
text = lipgloss.AdaptiveColor{Light: "#1F2329", Dark: "#E8E8E8"}
subtext = lipgloss.AdaptiveColor{Light: "#8F959E", Dark: "#8F959E"}
btnBg = lipgloss.AdaptiveColor{Light: "#EEF3FF", Dark: "#2B3A5C"}
)
t.Focused.Base = t.Focused.Base.BorderForeground(blue)
t.Focused.Card = t.Focused.Base
t.Focused.Title = t.Focused.Title.Foreground(blue).Bold(true)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(blue).Bold(true)
t.Focused.Description = t.Focused.Description.Foreground(subtext)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(magenta)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(magenta)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(teal)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(teal)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(teal)
t.Focused.Option = t.Focused.Option.Foreground(text)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(teal)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(cyan)
t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(cyan).SetString("✓ ")
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text)
t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(subtext).SetString("• ")
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(orange).Bold(true)
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(btnBg)
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(teal)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtext)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(teal)
t.Blurred = t.Focused
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
t.Blurred.Card = t.Blurred.Base
t.Blurred.NextIndicator = lipgloss.NewStyle()
t.Blurred.PrevIndicator = lipgloss.NewStyle()
t.Group.Title = t.Focused.Title
t.Group.Description = t.Focused.Description
return t
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"github.com/spf13/cobra"
)
const tipsAnnotationKey = "tips"
// SetTips sets the tips for a command (stored as JSON in Annotations).
func SetTips(cmd *cobra.Command, tips []string) {
if len(tips) == 0 {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
data, _ := json.Marshal(tips)
cmd.Annotations[tipsAnnotationKey] = string(data)
}
// AddTips appends tips to a command (merges with existing).
func AddTips(cmd *cobra.Command, tips ...string) {
existing := GetTips(cmd)
SetTips(cmd, append(existing, tips...))
}
// GetTips retrieves the tips from a command's annotations.
func GetTips(cmd *cobra.Command) []string {
if cmd.Annotations == nil {
return nil
}
raw, ok := cmd.Annotations[tipsAnnotationKey]
if !ok {
return nil
}
var tips []string
err := json.Unmarshal([]byte(raw), &tips)
if err != nil {
return nil
}
return tips
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestSetTipsAndGetTips(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
tips := []string{"tip one", "tip two"}
SetTips(cmd, tips)
got := GetTips(cmd)
if len(got) != 2 || got[0] != "tip one" || got[1] != "tip two" {
t.Fatalf("expected %v, got %v", tips, got)
}
}
func TestSetTipsEmpty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetTips(cmd, nil)
if cmd.Annotations != nil {
t.Fatal("expected nil annotations for empty tips")
}
}
func TestGetTipsNoAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
got := GetTips(cmd)
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
func TestAddTips(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetTips(cmd, []string{"first"})
AddTips(cmd, "second", "third")
got := GetTips(cmd)
if len(got) != 3 || got[0] != "first" || got[1] != "second" || got[2] != "third" {
t.Fatalf("expected [first second third], got %v", got)
}
}
func TestAddTipsToEmpty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
AddTips(cmd, "only")
got := GetTips(cmd)
if len(got) != 1 || got[0] != "only" {
t.Fatalf("expected [only], got %v", got)
}
}
+186
View File
@@ -0,0 +1,186 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"net/http"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/transport"
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
// and network errors. MaxRetries defaults to 0 (no retries).
type RetryTransport struct {
Base http.RoundTripper
MaxRetries int
Delay time.Duration // base delay for exponential backoff; defaults to 500ms
}
func (t *RetryTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *RetryTransport) delay() time.Duration {
if t.Delay > 0 {
return t.Delay
}
return 500 * time.Millisecond
}
// RoundTrip implements http.RoundTripper.
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)
if t.MaxRetries <= 0 {
return resp, err
}
for attempt := 0; attempt < t.MaxRetries; attempt++ {
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
// Clone request for retry
cloned := req.Clone(req.Context())
if req.Body != nil && req.GetBody != nil {
cloned.Body, _ = req.GetBody()
}
delay := t.delay() * (1 << uint(attempt))
time.Sleep(delay)
resp, err = t.base().RoundTrip(cloned)
}
return resp, err
}
// UserAgentTransport is an http.RoundTripper that sets the User-Agent header.
// Used in the SDK transport chain to override the SDK's default User-Agent.
type UserAgentTransport struct {
Base http.RoundTripper
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderUserAgent, UserAgentValue())
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return transport.Fallback().RoundTrip(req)
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
// X-Cli-Build header before every request. Used in the SDK transport chain,
// where SecurityHeaderTransport is not installed, to prevent extensions from
// tampering with the build classification. The direct HTTP chain is already
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
type BuildHeaderTransport struct {
Base http.RoundTripper
}
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderBuild, DetectBuildKind())
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return transport.Fallback().RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
// headers into every request. Shortcut headers are read from the request context.
type SecurityHeaderTransport struct {
Base http.RoundTripper
}
func (t *SecurityHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
for k, vs := range BaseSecurityHeaders() {
for _, v := range vs {
req.Header.Set(k, v)
}
}
// Shortcut headers are propagated via context (see section 5.6 of the design doc).
if name, ok := ShortcutNameFromContext(req.Context()); ok {
req.Header.Set(HeaderShortcut, name)
}
if eid, ok := ExecutionIdFromContext(req.Context()); ok {
req.Header.Set(HeaderExecutionId, eid)
}
return t.base().RoundTrip(req)
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes unless the extension is an
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
// error; it cannot otherwise be skipped or overridden.
//
// The original request context is restored after the pre hook to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
// Cloning the request isolates header/URL/etc. mutations from the caller's
// request object; req.Body is intentionally shared — extensions that consume
// it are responsible for rewinding (see Interceptor doc).
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
}
// RoundTrip invokes the interceptor pre hook, restores the original context,
// executes the built-in chain (unless aborted), then calls the post hook if
// non-nil. When the extension implements AbortableInterceptor and returns a
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
// *exttransport.AbortError is returned; the post hook is still invoked with
// (nil, reason) so extensions can unwind resources.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortEr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortEr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortEr != nil {
if post != nil {
post(nil, abortEr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
}
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// wrapWithExtension wraps transport with the registered extension middleware.
// If no extension is registered, returns transport unchanged.
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
p := exttransport.GetProvider()
if p == nil {
return transport
}
tr := p.ResolveInterceptor(context.Background())
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
}
+531
View File
@@ -0,0 +1,531 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// ---------------------------------------------------------------------------
// RetryTransport
// ---------------------------------------------------------------------------
func TestRetryTransport_NoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 0}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call, got %d", calls)
}
}
func TestRetryTransport_RetryOn500(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls < 3 {
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
})
rt := &RetryTransport{Base: base} // default MaxRetries=0
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 500 {
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call with default config, got %d", calls)
}
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
// ---------------------------------------------------------------------------
// extensionMiddleware — legacy Interceptor path
// ---------------------------------------------------------------------------
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
func (s *stubTransportProvider) Name() string { return "stub" }
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if s.interceptor != nil {
return s.interceptor
}
return &stubTransportImpl{}
}
type stubTransportImpl struct{}
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
// whether PostRoundTrip was called, to verify execution order.
type headerCapturingInterceptor struct {
preCalled bool
postCalled bool
}
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
h.preCalled = true
// Set a custom header that should survive (no built-in override)
req.Header.Set("X-Custom-Trace", "ext-trace-123")
// Try to override a security header — should be overwritten by SecurityHeaderTransport
req.Header.Set(HeaderSource, "ext-tampered")
return func(resp *http.Response, err error) {
h.postCalled = true
}
}
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var receivedHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ic := &headerCapturingInterceptor{}
exttransport.Register(&stubTransportProvider{interceptor: ic})
t.Cleanup(func() { exttransport.Register(nil) })
// Use HTTP transport chain (has SecurityHeaderTransport)
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// PreRoundTrip was called
if !ic.preCalled {
t.Fatal("PreRoundTrip was not called")
}
// PostRoundTrip (closure) was called
if !ic.postCalled {
t.Fatal("PostRoundTrip closure was not called")
}
// Custom header set by extension survives (no built-in override)
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
}
// Security header overridden by extension is restored by SecurityHeaderTransport
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
}
}
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
// value before the request leaves the process.
type buildTamperingInterceptor struct{}
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Del(HeaderBuild)
req.Header.Set(HeaderBuild, "ext-tampered-build")
return nil
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
// closes the gap where the SDK chain had no equivalent of
// SecurityHeaderTransport (see design doc §3.3.3).
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
base = &BuildHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if receivedBuild == "ext-tampered-build" {
t.Fatalf("%s = %q, extension tampering leaked to network", HeaderBuild, receivedBuild)
}
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
}
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
// no extension is registered, BuildHeaderTransport writes X-Cli-Build.
func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
transport := &BuildHeaderTransport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if receivedBuild == "" {
t.Fatalf("%s header missing, BuildHeaderTransport did not inject", HeaderBuild)
}
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
}
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// transport.Fallback rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
transport := &BuildHeaderTransport{Base: nil}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request via nil-Base transport failed: %v", err)
}
resp.Body.Close()
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q (header must be set even on nil-Base path)",
HeaderBuild, receivedBuild, want)
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
type ctxKeyType string
const testKey ctxKeyType = "original"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var ctxValue any
// Use a custom transport that captures the context value seen by the built-in chain
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
ctxValue = req.Context().Value(testKey)
return http.DefaultTransport.RoundTrip(req)
})
// Interceptor that tries to tamper with context
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
// Try to replace context with a new one
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// Built-in chain should see original context, not tampered
if ctxValue != "original" {
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
}
}
// ---------------------------------------------------------------------------
// extensionMiddleware — PreRoundTripE abort path
// ---------------------------------------------------------------------------
// abortingInterceptor implements exttransport.AbortableInterceptor and
// records invocation of the pre and post hooks. These middleware tests only
// assert middleware-level integration; pure *AbortError behavior
// (Error/Unwrap/Is/As) is covered in extension/transport/errors_test.go.
type abortingInterceptor struct {
reason error // if non-nil, PreRoundTripE returns this to abort
nilPost bool // if true, PreRoundTripE returns a nil post func
preECalled bool
postCalled bool
postResp *http.Response
postErr error
}
// PreRoundTrip is a no-op that satisfies the legacy Interceptor method; the
// middleware never calls it when PreRoundTripE is present.
func (*abortingInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
return nil
}
func (a *abortingInterceptor) PreRoundTripE(req *http.Request) (func(*http.Response, error), error) {
a.preECalled = true
if a.nilPost {
return nil, a.reason
}
return func(resp *http.Response, err error) {
a.postCalled = true
a.postResp = resp
a.postErr = err
}, a.reason
}
func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
innerErr := errors.New("denied by policy")
t.Run("skips base and wires AbortError fields", func(t *testing.T) {
ic := &abortingInterceptor{reason: innerErr}
baseCalls := 0
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if resp != nil {
t.Fatalf("resp = %v, want nil on abort", resp)
}
if baseCalls != 0 {
t.Fatalf("base RoundTrip called %d times on abort, want 0", baseCalls)
}
if !ic.preECalled {
t.Fatal("PreRoundTripE was not called")
}
var aErr *exttransport.AbortError
if !errors.As(err, &aErr) {
t.Fatalf("errors.As(*AbortError) = false, err = %v (%T)", err, err)
}
if aErr.Extension != "stub" || aErr.Reason != innerErr {
t.Fatalf("AbortError = %+v, want {Extension:stub Reason:%v}", aErr, innerErr)
}
// Post must see the original inner err, not the *AbortError wrapper.
if !ic.postCalled {
t.Fatal("post hook was not called on abort")
}
if ic.postResp != nil {
t.Fatalf("post resp = %v, want nil", ic.postResp)
}
if ic.postErr != innerErr {
t.Fatalf("post err = %v, want original inner err %v", ic.postErr, innerErr)
}
})
t.Run("nil post still returns AbortError", func(t *testing.T) {
ic := &abortingInterceptor{reason: innerErr, nilPost: true}
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("base must not be called on abort")
return nil, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
_, err := mid.RoundTrip(req)
var aErr *exttransport.AbortError
if !errors.As(err, &aErr) {
t.Fatalf("errors.As(*AbortError) = false, err = %v", err)
}
})
}
func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
ic := &abortingInterceptor{} // reason == nil → no abort
baseCalls := 0
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("happy path returned err: %v", err)
}
if resp == nil || resp.StatusCode != http.StatusOK {
t.Fatalf("resp = %v, want 200", resp)
}
if baseCalls != 1 {
t.Fatalf("base RoundTrip called %d times, want 1", baseCalls)
}
if !ic.preECalled {
t.Fatal("PreRoundTripE was not called")
}
if !ic.postCalled || ic.postErr != nil {
t.Fatalf("post hook not called or err != nil: called=%v err=%v", ic.postCalled, ic.postErr)
}
}