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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user