mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
2 Commits
feat/frame
...
docs/wiki-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e19aae336 | ||
|
|
6f542aafe2 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,35 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.81] - 2026-07-31
|
||||
|
||||
### Features
|
||||
|
||||
- support visible_rule for form questions (#1891)
|
||||
- **contact**: add bot search shortcut (#2083)
|
||||
- add SXSD schema validation to Slides lint (#2103)
|
||||
- **drive**: add comment-operation shortcuts (#1898)
|
||||
- **drive**: extend permission shortcuts for Miaoda (#2070)
|
||||
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
|
||||
- support source file preview artifacts (#2085)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
|
||||
- **base**: resolve Base URL block types accurately (#2099)
|
||||
- **drive**: use title for default download filename (#2089)
|
||||
- drop stale target version from root upgrade prompt (#2100)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: warn against container-default timezone in time conversion (#2104)
|
||||
- **calendar**: confirm scope before editing recurring events (#2119)
|
||||
- **base**: clarify form and file operation routing (#2110)
|
||||
|
||||
### Misc
|
||||
|
||||
- add protected public domain allowlists (#2111)
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
@@ -1751,7 +1722,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
|
||||
@@ -310,6 +310,10 @@ lark-cli config risk-control default
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
|
||||
|
||||
@@ -311,6 +311,10 @@ lark-cli config risk-control default
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
@@ -179,8 +179,8 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
// Registration is platform traffic, so it must use the provider-aware
|
||||
// transport as well as the shared proxy configuration.
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
|
||||
@@ -157,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity checks are platform traffic and must exercise the same
|
||||
// provider-aware route as real platform requests.
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
|
||||
@@ -12,18 +12,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
@@ -272,55 +263,3 @@ func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(&Provider{})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seen := make(chan *http.Request, 2)
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seen <- req.Clone(req.Context())
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
resp, err := client.Do(withSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = client.Do(withoutSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
proxied := <-seen
|
||||
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
|
||||
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
|
||||
}
|
||||
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
|
||||
t.Fatalf("sentinel request proxy target = %q", got)
|
||||
}
|
||||
|
||||
passthrough := <-seen
|
||||
if got := passthrough.URL.String(); got != "https://external.example/public" {
|
||||
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,27 +15,6 @@ type Provider interface {
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// RequestClass describes the trust boundary of an outbound HTTP request.
|
||||
// Platform requests target endpoints owned by the CLI's endpoint resolver;
|
||||
// external requests target user-provided, pre-signed, CDN, registry, or other
|
||||
// non-platform URLs. Redirect targets are classified again from each hop's
|
||||
// logical URL; rewriting a host in an interceptor does not add that host to
|
||||
// the platform endpoint catalog.
|
||||
type RequestClass string
|
||||
|
||||
const (
|
||||
RequestClassPlatform RequestClass = "platform"
|
||||
RequestClassExternal RequestClass = "external"
|
||||
)
|
||||
|
||||
// ScopedProvider optionally limits a Provider to selected request classes.
|
||||
// Providers that do not implement this interface retain the original
|
||||
// behavior and apply to every request class.
|
||||
type ScopedProvider interface {
|
||||
Provider
|
||||
SupportsRequestClass(RequestClass) bool
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
// and checks for security policy errors.
|
||||
type SecurityPolicyTransport struct {
|
||||
@@ -33,16 +31,6 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base().RoundTrip(req)
|
||||
|
||||
@@ -212,9 +212,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
@@ -518,29 +518,6 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
|
||||
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, policyErr
|
||||
})},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/files/file_token/download",
|
||||
}, core.AsBot)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !errors.Is(err, policyErr) {
|
||||
t.Fatal("DoStream() did not preserve the typed transport error")
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
@@ -16,12 +16,10 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
@@ -33,7 +31,7 @@ type InvocationContext struct {
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
|
||||
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
|
||||
|
||||
@@ -50,18 +48,6 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ExternalHTTPClient returns a clone of the existing Factory client whose
|
||||
// requests are explicitly classified as external. The underlying client,
|
||||
// redirect policy, timeout, proxy configuration, and legacy transport provider
|
||||
// behavior are preserved.
|
||||
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
|
||||
client, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -5,18 +5,16 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -50,19 +48,6 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
core.SetCurrentWorkspace(ws)
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
|
||||
return resolveSDKHostSignalSource(workspaceConfig)
|
||||
})
|
||||
// Install after workspace selection so the dependency bootstrap bridge uses
|
||||
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
|
||||
// consumers, so this keeps their request routing identical to cmd.Execute.
|
||||
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return buildSDKPlatformTransportWithBase(
|
||||
base,
|
||||
bootstrapHostSignalSource(),
|
||||
)
|
||||
})
|
||||
|
||||
// Inject workspace-aware dir into keychain's log system.
|
||||
// This breaks the core↔keychain import cycle by using a function variable.
|
||||
@@ -70,6 +55,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
@@ -101,45 +87,15 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
|
||||
// HEAD requests. This allows API download redirects while preventing OAuth or
|
||||
// other credential-bearing request bodies from being replayed to another
|
||||
// origin. HTTPS requests can never be downgraded to HTTP.
|
||||
// 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 errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
original := via[0]
|
||||
previous := via[len(via)-1]
|
||||
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"redirect from HTTPS to %s is not allowed",
|
||||
req.URL.Scheme,
|
||||
)
|
||||
}
|
||||
if !sameRedirectOrigin(previous.URL, req.URL) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect for HTTP method %s is not allowed",
|
||||
req.Method,
|
||||
)
|
||||
}
|
||||
if req.Body != nil || req.GetBody != nil {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect with a request body is not allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
// net/http copies initial headers onto every redirect request. Continue
|
||||
// stripping credentials for every hop outside the initial origin, even when
|
||||
// two consecutive redirect targets share an origin.
|
||||
if !sameRedirectOrigin(original.URL, req.URL) {
|
||||
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")
|
||||
@@ -147,29 +103,6 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRedirectOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectivePort(left) == effectivePort(right)
|
||||
}
|
||||
|
||||
func effectivePort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -185,12 +118,15 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
shared := transport.Shared()
|
||||
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
|
||||
platform := buildDirectHTTPTransport(outbound, true)
|
||||
external := buildDirectHTTPTransport(outbound, false)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||
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: transport.NewHTTPPolicyRouter(platform, external),
|
||||
Transport: rt,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -198,15 +134,6 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
@@ -222,8 +149,14 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
var sdkBase http.RoundTripper = transport.Shared()
|
||||
// The innermost SDK boundary always strips reserved host-signal headers;
|
||||
// a nil source makes it strip-only when workspace policy disables signal
|
||||
// collection.
|
||||
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
|
||||
sdkTransport := wrapSDKTransport(sdkBase)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(hostSignalSource),
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -232,41 +165,12 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
|
||||
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
|
||||
}
|
||||
|
||||
func buildSDKPlatformTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return buildSDKHTTPTransport(outbound, true)
|
||||
}
|
||||
|
||||
func buildSDKTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
// Risk control is the innermost trusted boundary for both request classes.
|
||||
// It therefore observes the final URL and strips extension-supplied reserved
|
||||
// headers immediately before the network transport.
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return transport.NewHTTPPolicyRouter(
|
||||
buildSDKHTTPTransport(outbound, true),
|
||||
buildSDKHTTPTransport(outbound, false),
|
||||
)
|
||||
}
|
||||
|
||||
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &UserAgentTransport{Base: builtIn}
|
||||
builtIn = &BuildHeaderTransport{Base: builtIn}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -40,7 +33,7 @@ func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -51,7 +44,7 @@ func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -61,283 +54,3 @@ func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
|
||||
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
|
||||
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if external == base {
|
||||
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
|
||||
}
|
||||
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
|
||||
t.Fatal("ExternalHTTPClient did not preserve client policy")
|
||||
}
|
||||
if base.Transport != nil {
|
||||
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
|
||||
}
|
||||
}
|
||||
|
||||
type platformOnlyStubProvider struct {
|
||||
*stubTransportProvider
|
||||
}
|
||||
|
||||
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassPlatform
|
||||
}
|
||||
|
||||
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
externalClient, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, client := range []*http.Client{platformClient, externalClient} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
platformHeaders := <-received
|
||||
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
|
||||
}
|
||||
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
|
||||
}
|
||||
|
||||
externalHeaders := <-received
|
||||
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
|
||||
t.Fatalf("external request leaked extension header %q", got)
|
||||
}
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := externalHeaders.Get(header); got != want {
|
||||
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
exttransport.Register(nil)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
|
||||
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
if _, err := platform.Get(server.URL); err == nil {
|
||||
t.Fatal("platform request error = nil, want security policy classification")
|
||||
} else {
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) {
|
||||
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
|
||||
}
|
||||
}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := external.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("external request parsed platform error protocol: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
redirect.Header.Set(header, "secret")
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
if got := redirect.Header.Get(header); got != "" {
|
||||
t.Fatalf("redirect retained %s=%q", header, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "request body") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
|
||||
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
|
||||
if err == nil || err.Error() != "too many redirects" {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
|
||||
}
|
||||
if got := redirect.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed error", err)
|
||||
}
|
||||
if problem.Category != category || problem.Subtype != subtype {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %s/%s, want %s/%s",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
category,
|
||||
subtype,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ var proxyWarnGateCases = []struct {
|
||||
{"non-terminal stderr stays silent", false, 0},
|
||||
}
|
||||
|
||||
// TestCachedHTTPClientFunc_ProxyWarnGate verifies the HTTP client init path
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHTTPClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
|
||||
URL: "/test",
|
||||
Body: "ok",
|
||||
})
|
||||
// Use the stub via Factory HttpClient.
|
||||
// Use the stub via Factory HttpClient
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("HttpClient() error: %v", err)
|
||||
|
||||
@@ -4,19 +4,14 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
// and network errors. MaxRetries defaults to 0 (no retries).
|
||||
type RetryTransport struct {
|
||||
@@ -32,16 +27,6 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
if t.Delay > 0 {
|
||||
return t.Delay
|
||||
@@ -78,19 +63,6 @@ type UserAgentTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderUserAgent, UserAgentValue())
|
||||
@@ -101,25 +73,14 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
// X-Cli-Build header before every request. It remains in the SDK transport
|
||||
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
|
||||
// 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) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderBuild, DetectBuildKind())
|
||||
@@ -142,16 +103,6 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
@@ -169,3 +120,67 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
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()}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -91,107 +91,94 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildSDKTransport policy behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
for _, class := range []exttransport.RequestClass{
|
||||
exttransport.RequestClassPlatform,
|
||||
exttransport.RequestClassExternal,
|
||||
} {
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
class,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
headers := <-received
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := headers.Get(header); got != want {
|
||||
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if !interceptor.preCalled || !interceptor.postCalled {
|
||||
t.Fatal("SDK platform request did not execute extension pre/post hooks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
|
||||
t.Fatalf(
|
||||
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
|
||||
buildSDKTransport(nil),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
base := &http.Transport{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
source, ok := client.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
rebuilt, concrete, ok := source.CloneHTTPTransport()
|
||||
if !ok || rebuilt == nil || concrete == nil {
|
||||
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
if concrete == base {
|
||||
t.Fatal("SDK policy graph reused the original HTTP transport")
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → 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)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → 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)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +238,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -279,16 +266,14 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildTamperingInterceptor tries to delete and spoof security headers via
|
||||
// PreRoundTrip. The SDK built-in chain must restore the real values before the
|
||||
// request leaves the process.
|
||||
// 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")
|
||||
req.Header.Del(HeaderSource)
|
||||
req.Header.Set(HeaderSource, "ext-tampered-source")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -300,74 +285,7 @@ func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http
|
||||
return nil
|
||||
}
|
||||
|
||||
type bootstrapPolicyTamperingInterceptor struct{}
|
||||
|
||||
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(HeaderSource, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldTransport := http.DefaultClient.Transport
|
||||
oldCheckRedirect := http.DefaultClient.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient.Transport = oldTransport
|
||||
http.DefaultClient.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{
|
||||
interceptor: bootstrapPolicyTamperingInterceptor{},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
var received http.Header
|
||||
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
http.DefaultClient.Transport = network
|
||||
http.DefaultClient.CheckRedirect = nil
|
||||
_ = NewDefault(nil, InvocationContext{})
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://open.feishu.cn/callback/ws/endpoint",
|
||||
strings.NewReader(`{"app_secret":"secret"}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := received.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
|
||||
}
|
||||
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
|
||||
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
|
||||
}
|
||||
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
|
||||
}
|
||||
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
@@ -383,11 +301,7 @@ func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Do(req)
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -398,13 +312,14 @@ func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// SDK chain restores both the build classification and the full security
|
||||
// header set after an extension runs.
|
||||
// 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, receivedSource string
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
receivedSource = r.Header.Get(HeaderSource)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -412,13 +327,12 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK built-in chain inside buildSDKTransport.
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -435,9 +349,6 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
if receivedSource != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
|
||||
@@ -527,7 +438,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
@@ -589,7 +500,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -630,7 +541,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
_, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -649,7 +560,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -66,39 +63,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
var platformEndpointHosts = func() map[string]struct{} {
|
||||
hosts := make(map[string]struct{})
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := ResolveEndpoints(brand)
|
||||
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err == nil && parsed.Hostname() != "" {
|
||||
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}()
|
||||
|
||||
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
|
||||
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
|
||||
// suffix match: lookalike external domains must never enter the platform
|
||||
// transport extension.
|
||||
func IsPlatformEndpointHost(hostname string) bool {
|
||||
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
|
||||
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
|
||||
// excluded even when their hostname matches.
|
||||
func IsPlatformEndpointURL(candidate *url.URL) bool {
|
||||
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
|
||||
return false
|
||||
}
|
||||
if port := candidate.Port(); port != "" && port != "443" {
|
||||
return false
|
||||
}
|
||||
return IsPlatformEndpointHost(candidate.Hostname())
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
@@ -95,85 +91,3 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"open.feishu.cn",
|
||||
"accounts.feishu.cn",
|
||||
"mcp.feishu.cn",
|
||||
"applink.feishu.cn",
|
||||
"open.larksuite.com",
|
||||
"accounts.larksuite.com",
|
||||
"mcp.larksuite.com",
|
||||
"applink.larksuite.com",
|
||||
} {
|
||||
if !IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{
|
||||
"example.com",
|
||||
"open.feishu.cn.example.com",
|
||||
"notopen.feishu.cn",
|
||||
"",
|
||||
} {
|
||||
if IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
|
||||
for i := 0; i < endpoints.NumField(); i++ {
|
||||
rawURL := endpoints.Field(i).String()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
|
||||
}
|
||||
if !IsPlatformEndpointHost(parsed.Hostname()) {
|
||||
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
|
||||
if IsPlatformEndpointURL(nil) {
|
||||
t.Error("IsPlatformEndpointURL(nil) = true, want false")
|
||||
}
|
||||
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
|
||||
if !IsPlatformEndpointURL(uppercaseScheme) {
|
||||
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:8443/path",
|
||||
"https://open.feishu.cn.example.com/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:443/path",
|
||||
"https://OPEN.FEISHU.CN/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package flagalias owns parse-time aliases for Cobra/pflag commands.
|
||||
//
|
||||
// An alias is another accepted spelling of one canonical flag. It is not a
|
||||
// second pflag: parsing an alias resolves to the canonical flag before pflag
|
||||
// applies the value, so type, default, Changed state, required/enum/input
|
||||
// contracts, and repeated-flag behavior all stay attached to one object.
|
||||
//
|
||||
// Value conversion for non-equivalent legacy inputs is a business compatibility
|
||||
// concern, not an alias. Exact aliases always use the canonical flag's native
|
||||
// occurrence semantics; domains must not add a separate conflict policy.
|
||||
package flagalias
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// AnnotationAliases is attached to the canonical pflag. Consumers should use
|
||||
// Aliases instead of reading the annotation directly.
|
||||
const AnnotationAliases = "lark-cli/flag-aliases"
|
||||
|
||||
// Spec binds Aliases to one Canonical long-flag name. Names do not include the
|
||||
// leading "--".
|
||||
type Spec struct {
|
||||
Canonical string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
// Bind installs exact-name aliases on cmd and records them on their canonical
|
||||
// pflags for manifest/tooling introspection. Existing pflag normalization is
|
||||
// composed first; alias resolution is then applied to the normalized spelling.
|
||||
//
|
||||
// Bind is intentionally the only production owner of SetNormalizeFunc. It
|
||||
// validates the complete accepted-name set before installing alias metadata or
|
||||
// a normalizer, so a configuration error cannot leave aliases partially bound.
|
||||
func Bind(cmd *cobra.Command, specs []Spec) error {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if cmd == nil {
|
||||
return fmt.Errorf("bind flag aliases: command is nil")
|
||||
}
|
||||
|
||||
cmd.InitDefaultHelpFlag()
|
||||
flagSet := cmd.Flags()
|
||||
previous := flagSet.GetNormalizeFunc()
|
||||
normalize := func(name string) string {
|
||||
if previous == nil {
|
||||
return name
|
||||
}
|
||||
return string(previous(flagSet, name))
|
||||
}
|
||||
|
||||
registered := make(map[string]string)
|
||||
collectRegistered(registered, flagSet)
|
||||
collectRegistered(registered, cmd.InheritedFlags())
|
||||
|
||||
// Existing annotations matter when Bind is composed by multiple adapters:
|
||||
// aliases are not independent pflags, so VisitAll alone cannot see them.
|
||||
acceptedAliases := make(map[string]string)
|
||||
collectAnnotatedAliases(acceptedAliases, flagSet, normalize)
|
||||
collectAnnotatedAliases(acceptedAliases, cmd.InheritedFlags(), normalize)
|
||||
|
||||
aliases := make(map[string]string)
|
||||
metadata := make(map[*pflag.Flag][]string)
|
||||
seenCanonical := make(map[string]struct{})
|
||||
for _, spec := range specs {
|
||||
if len(spec.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
canonicalFlag := flagSet.Lookup(spec.Canonical)
|
||||
if canonicalFlag == nil {
|
||||
return fmt.Errorf("%s declares aliases for unregistered flag --%s", cmd.CommandPath(), spec.Canonical)
|
||||
}
|
||||
canonical := canonicalFlag.Name
|
||||
if _, exists := seenCanonical[canonical]; exists {
|
||||
return fmt.Errorf("%s declares flag aliases for --%s more than once after normalization", cmd.CommandPath(), canonical)
|
||||
}
|
||||
seenCanonical[canonical] = struct{}{}
|
||||
for _, alias := range spec.Aliases {
|
||||
if err := validateAliasName(alias); err != nil {
|
||||
return fmt.Errorf("%s alias for --%s: %w", cmd.CommandPath(), canonical, err)
|
||||
}
|
||||
normalized := normalize(alias)
|
||||
if normalized == "" {
|
||||
return fmt.Errorf("%s alias --%s for --%s normalizes to an empty name", cmd.CommandPath(), alias, canonical)
|
||||
}
|
||||
if normalized == canonical {
|
||||
return fmt.Errorf("%s declares --%s as an alias of itself (--%s after normalization)", cmd.CommandPath(), alias, canonical)
|
||||
}
|
||||
if existing, ok := registered[normalized]; ok {
|
||||
return fmt.Errorf("%s alias --%s for --%s conflicts with registered flag --%s after normalization", cmd.CommandPath(), alias, canonical, existing)
|
||||
}
|
||||
if existing, ok := acceptedAliases[normalized]; ok {
|
||||
return fmt.Errorf("%s alias --%s for --%s conflicts with existing alias for --%s after normalization to --%s", cmd.CommandPath(), alias, canonical, existing, normalized)
|
||||
}
|
||||
if existing, ok := aliases[normalized]; ok {
|
||||
if existing == canonical {
|
||||
return fmt.Errorf("%s declares duplicate alias --%s for --%s after normalization to --%s", cmd.CommandPath(), alias, canonical, normalized)
|
||||
}
|
||||
return fmt.Errorf("%s alias --%s maps to both --%s and --%s after normalization to --%s", cmd.CommandPath(), alias, existing, canonical, normalized)
|
||||
}
|
||||
aliases[normalized] = canonical
|
||||
metadata[canonicalFlag] = append(metadata[canonicalFlag], alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
for flag, names := range metadata {
|
||||
setAliases(flag, append(Aliases(flag), names...))
|
||||
}
|
||||
flagSet.SetNormalizeFunc(func(set *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
normalized := name
|
||||
if previous != nil {
|
||||
normalized = string(previous(set, name))
|
||||
}
|
||||
if canonical, ok := aliases[normalized]; ok {
|
||||
return pflag.NormalizedName(canonical)
|
||||
}
|
||||
return pflag.NormalizedName(normalized)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustBind is the flag-registration form of Bind. Cobra/pflag registration
|
||||
// already treats duplicate or invalid flag definitions as programmer errors;
|
||||
// MustBind preserves that startup-fail-fast contract for callers whose mount
|
||||
// API does not return an error.
|
||||
func MustBind(cmd *cobra.Command, specs []Spec) {
|
||||
if err := Bind(cmd, specs); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Aliases returns a defensive copy of the raw accepted alias spellings stored
|
||||
// on a canonical pflag. Alias order matches declaration order.
|
||||
func Aliases(flag *pflag.Flag) []string {
|
||||
if flag == nil || len(flag.Annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), flag.Annotations[AnnotationAliases]...)
|
||||
}
|
||||
|
||||
func validateAliasName(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return fmt.Errorf("name must not be empty")
|
||||
case strings.HasPrefix(name, "-"):
|
||||
return fmt.Errorf("name %q must not include leading dashes", name)
|
||||
case strings.ContainsAny(name, " \t\r\n"):
|
||||
return fmt.Errorf("name %q must not contain whitespace", name)
|
||||
case strings.Contains(name, "="):
|
||||
return fmt.Errorf("name %q must not contain '='", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func collectRegistered(dst map[string]string, set *pflag.FlagSet) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
dst[flag.Name] = flag.Name
|
||||
})
|
||||
}
|
||||
|
||||
func collectAnnotatedAliases(dst map[string]string, set *pflag.FlagSet, normalize func(string) string) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
for _, alias := range Aliases(flag) {
|
||||
dst[normalize(alias)] = flag.Name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setAliases(flag *pflag.Flag, aliases []string) {
|
||||
if flag.Annotations == nil {
|
||||
flag.Annotations = make(map[string][]string)
|
||||
}
|
||||
flag.Annotations[AnnotationAliases] = append([]string(nil), aliases...)
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package flagalias
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestBindResolvesAliasesToOneCanonicalFlag(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "desc", "message order")
|
||||
if err := cmd.MarkFlagRequired("order"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort", "sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cmd.ParseFlags([]string{"--sort-order", "asc"}); err != nil {
|
||||
t.Fatalf("ParseFlags(alias) error = %v", err)
|
||||
}
|
||||
canonical := cmd.Flags().Lookup("order")
|
||||
if got := canonical.Value.String(); got != "asc" {
|
||||
t.Fatalf("canonical value = %q, want asc", got)
|
||||
}
|
||||
if !canonical.Changed {
|
||||
t.Fatal("alias must mark canonical flag Changed")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err != nil {
|
||||
t.Fatalf("alias must satisfy required canonical flag: %v", err)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("sort-order"); got != canonical {
|
||||
t.Fatalf("Lookup(alias) = %p, want canonical %p", got, canonical)
|
||||
}
|
||||
if got := Aliases(canonical); strings.Join(got, ",") != "sort,sort-order" {
|
||||
t.Fatalf("Aliases(canonical) = %v", got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--sort") {
|
||||
t.Fatalf("aliases leaked into help:\n%s", usage)
|
||||
}
|
||||
var names []string
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) { names = append(names, flag.Name) })
|
||||
if strings.Contains(strings.Join(names, ","), "sort") {
|
||||
t.Fatalf("aliases were registered as independent flags: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindUsesNativeRepeatedFlagSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "alias last", args: []string{"--order", "asc", "--sort", "desc"}, want: "desc"},
|
||||
{name: "canonical last", args: []string{"--sort", "desc", "--order", "asc"}, want: "asc"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags(test.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != test.want {
|
||||
t.Fatalf("order = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().StringSlice("fields", nil, "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "fields", Aliases: []string{"field"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--field", "name", "--fields", "status"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetStringSlice("fields"); strings.Join(got, ",") != "name,status" {
|
||||
t.Fatalf("collection aliases did not accumulate: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindComposesExistingNormalizer(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("order", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort_order", "asc"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q, want asc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsDuplicateCanonicalAfterNormalization(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("sort-order", "", "")
|
||||
|
||||
err := Bind(cmd, []Spec{
|
||||
{Canonical: "sort_order", Aliases: []string{"order"}},
|
||||
{Canonical: "sort-order", Aliases: []string{"ordering"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "more than once after normalization") {
|
||||
t.Fatalf("Bind() error = %v", err)
|
||||
}
|
||||
if got := Aliases(cmd.Flags().Lookup("sort-order")); len(got) != 0 {
|
||||
t.Fatalf("failed bind partially mutated annotations: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsAcceptedNameCollisionsWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
specs []Spec
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "registered canonical",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("query", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"query"}}},
|
||||
want: "conflicts with registered flag --query",
|
||||
},
|
||||
{
|
||||
name: "ambiguous alias",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("field", "", "")
|
||||
},
|
||||
specs: []Spec{
|
||||
{Canonical: "order", Aliases: []string{"sort"}},
|
||||
{Canonical: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
want: "maps to both",
|
||||
},
|
||||
{
|
||||
name: "normalized collision",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("sort-order", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"sort_order"}}},
|
||||
want: "after normalization",
|
||||
},
|
||||
{
|
||||
name: "invalid spelling",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"--sort"}}},
|
||||
want: "must not include leading dashes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
test.setup(cmd)
|
||||
err := Bind(cmd, test.specs)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Bind() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if got := Aliases(cmd.Flags().Lookup("order")); len(got) != 0 {
|
||||
t.Fatalf("failed bind partially mutated annotations: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsInheritedFlagCollision(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
parent.PersistentFlags().String("profile", "", "")
|
||||
child := &cobra.Command{Use: "messages"}
|
||||
child.Flags().String("order", "", "")
|
||||
parent.AddCommand(child)
|
||||
|
||||
err := Bind(child, []Spec{{Canonical: "order", Aliases: []string{"profile"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "registered flag --profile") {
|
||||
t.Fatalf("Bind() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindCanComposeIndependentAdapters(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("query", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Bind(cmd, []Spec{{Canonical: "query", Aliases: []string{"keyword"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort", "asc", "--keyword", "launch"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q", got)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("query"); got != "launch" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -59,25 +58,3 @@ func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
// writePaginationDiagnostic reports a record stream's pagination outcome on the
|
||||
// diagnostics stream, as one JSON object per line.
|
||||
//
|
||||
// A record stream has no envelope to carry meta, so without this a result
|
||||
// truncated by --page-limit is byte-identical to a complete one — the reader
|
||||
// cannot tell "these are all the records" from "these are the first 500". It is
|
||||
// JSON rather than prose because the reader that needs it is a program.
|
||||
func writePaginationDiagnostic(w io.Writer, meta PaginationMeta) error {
|
||||
payload := struct {
|
||||
Diagnostic string `json:"_diagnostic"`
|
||||
PaginationMeta
|
||||
}{Diagnostic: "pagination", PaginationMeta: meta}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "%s\n", encoded); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
@@ -35,11 +36,9 @@ type EmitterConfig struct {
|
||||
// EmitOptions describes one result's wire representation.
|
||||
//
|
||||
// The format contract is explicit: JSON (including the empty default) uses an
|
||||
// Envelope. Pretty and table render business data plus a human pagination
|
||||
// summary when supplied; csv and ndjson keep stdout as naked records and put
|
||||
// pagination metadata on the diagnostics stream. JQ takes precedence over
|
||||
// Format and filters the JSON Envelope. Raw affects only JSON envelope encoding
|
||||
// and jq's complex-value encoding.
|
||||
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
|
||||
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
|
||||
// envelope encoding and jq's complex-value encoding.
|
||||
//
|
||||
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
|
||||
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
|
||||
@@ -95,8 +94,8 @@ func NewEmitter(config EmitterConfig) *Emitter {
|
||||
}
|
||||
|
||||
// Success scans and emits one command result by composing the package's leaf
|
||||
// primitives. JSON and jq use the standard envelope; record formats keep their
|
||||
// stdout payload free of envelope metadata.
|
||||
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
|
||||
// ndjson render the business value directly.
|
||||
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
@@ -105,23 +104,14 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if opts.JQ != "" {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
if opts.Format == "pretty" {
|
||||
return e.emitPretty(data, opts)
|
||||
}
|
||||
|
||||
format, known := ParseFormat(opts.Format)
|
||||
if !known {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
switch opts.Format {
|
||||
case "", "json":
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case FormatTable, FormatCSV, FormatNDJSON:
|
||||
return e.emitFormatted(data, format, opts.Meta)
|
||||
case "pretty":
|
||||
return e.emitPretty(data, opts)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"unsupported output format %q", format)
|
||||
return e.emitFormatted(data, opts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +245,7 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
}
|
||||
if opts.Pretty != nil {
|
||||
return e.emit(func(w io.Writer) error {
|
||||
if err := opts.Pretty(w, e.colorEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePaginationSummary(w, opts.Meta)
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -268,10 +255,7 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
// emitFormatted handles only non-envelope formats. JSON, jq, and unknown-format
|
||||
// fallback are resolved by Success before reaching this function, so there is
|
||||
// exactly one JSON success contract: the standard Envelope.
|
||||
func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) error {
|
||||
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
@@ -282,49 +266,43 @@ func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) err
|
||||
}
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatTable:
|
||||
return e.emit(func(w io.Writer) error {
|
||||
if err := WriteFormatted(w, data, format); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePaginationSummary(w, meta)
|
||||
})
|
||||
case FormatCSV, FormatNDJSON:
|
||||
if err := e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
return writePaginationDiagnostic(e.errOut, *meta.Pagination)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"non-envelope emitter received unsupported format %q", format)
|
||||
format, known := ParseFormat(rawFormat)
|
||||
if !known && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
|
||||
}
|
||||
if format == FormatJSON {
|
||||
return e.printLegacyDataJSON(data)
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
})
|
||||
}
|
||||
|
||||
func writePaginationSummary(w io.Writer, meta *Meta) error {
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
pagination := meta.Pagination
|
||||
status := "complete"
|
||||
if !pagination.Complete {
|
||||
status = "incomplete"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\nPagination: %s (%d page(s), %d item(s))", status, pagination.Pages, pagination.Items); err != nil {
|
||||
return err
|
||||
}
|
||||
if !pagination.Complete && pagination.NextToken != "" {
|
||||
if _, err := fmt.Fprintf(w, "; resume token: %q", pagination.NextToken); err != nil {
|
||||
return err
|
||||
type emitterDataMap map[string]interface{}
|
||||
|
||||
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
|
||||
// data from this Emitter instead of PrintJson's global PendingNotice hook.
|
||||
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
|
||||
// Normalise structs / named maps to plain generic types first, exactly as
|
||||
// FormatValue does, so a struct or named-map payload still matches the map
|
||||
// case below and keeps its injected _notice on the unknown-format fallback.
|
||||
data = toGeneric(data)
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if _, isEnvelope := m["ok"]; isEnvelope {
|
||||
if notice := e.notice(); notice != nil {
|
||||
m = maps.Clone(m)
|
||||
m["_notice"] = notice
|
||||
}
|
||||
}
|
||||
// The named map retains identical JSON bytes while preventing PrintJson
|
||||
// from consulting its legacy global notice hook a second time.
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, emitterDataMap(m))
|
||||
})
|
||||
}
|
||||
_, err := fmt.Fprintln(w)
|
||||
return err
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, data)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emit(render func(io.Writer) error) error {
|
||||
|
||||
@@ -63,127 +63,6 @@ func TestEmitterSuccessWritesAllBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterPaginationMetadataByFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1", "name": "first"}},
|
||||
}
|
||||
meta := &output.Meta{
|
||||
Count: 1,
|
||||
Pagination: &output.PaginationMeta{
|
||||
Complete: false,
|
||||
Pages: 2,
|
||||
Items: 1,
|
||||
NextToken: "resume-token",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("json envelope", func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "json", Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
if envelope.Meta == nil || !reflect.DeepEqual(envelope.Meta.Pagination, meta.Pagination) {
|
||||
t.Fatalf("pagination meta = %#v, want %#v", envelope.Meta, meta.Pagination)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("json wrote pagination diagnostic to stderr: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown format falls back to the same json envelope", func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml", Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("fallback stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if envelope.Meta == nil || !reflect.DeepEqual(envelope.Meta.Pagination, meta.Pagination) {
|
||||
t.Fatalf("fallback pagination meta = %#v, want %#v", envelope.Meta, meta.Pagination)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `warning: unknown format "yaml", falling back to json`) {
|
||||
t.Fatalf("fallback stderr = %q, want unknown-format warning", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), `"_diagnostic":"pagination"`) {
|
||||
t.Fatalf("fallback emitted a second pagination contract: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
format string
|
||||
pretty output.PrettyRenderer
|
||||
}{
|
||||
{name: "pretty", format: "pretty", pretty: func(w io.Writer, _ bool) error {
|
||||
_, err := io.WriteString(w, "first\n")
|
||||
return err
|
||||
}},
|
||||
{name: "table", format: "table"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: tc.format, Pretty: tc.pretty, Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"Pagination: incomplete", "2 page(s)", "1 item(s)", `resume token: "resume-token"`} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("%s wrote pagination diagnostic to stderr: %q", tc.format, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, format := range []string{"ndjson", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: format, Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "_diagnostic") || strings.Contains(stdout.String(), "resume-token") {
|
||||
t.Fatalf("%s stdout was polluted by pagination metadata: %q", format, stdout.String())
|
||||
}
|
||||
var diagnostic struct {
|
||||
Diagnostic string `json:"_diagnostic"`
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
NextToken string `json:"next_token"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stderr.Bytes()), &diagnostic); err != nil {
|
||||
t.Fatalf("decode pagination diagnostic %q: %v", stderr.String(), err)
|
||||
}
|
||||
if diagnostic.Diagnostic != "pagination" || diagnostic.Complete || diagnostic.Pages != 2 || diagnostic.Items != 1 || diagnostic.NextToken != "resume-token" {
|
||||
t.Fatalf("pagination diagnostic = %+v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
|
||||
@@ -269,9 +269,16 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
// Unknown-format fallback is intentionally excluded from this frozen
|
||||
// legacy set: it now uses the standard JSON Envelope. The replacement
|
||||
// contract lives in TestEmitterPaginationMetadataByFormat.
|
||||
{
|
||||
name: "unknown_format_data_envelope_notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
},
|
||||
ok: true,
|
||||
format: "yaml",
|
||||
useFormat: true,
|
||||
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
|
||||
},
|
||||
}
|
||||
|
||||
golden := loadRuntimeContextLegacyGolden(t)
|
||||
@@ -725,7 +732,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("JSON envelope fallback consulted global notice:\n%s", stdout.String())
|
||||
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,24 +16,8 @@ type Envelope struct {
|
||||
|
||||
// Meta carries optional metadata in envelope responses.
|
||||
type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Pagination *PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
// PaginationMeta reports how a paginated read ended.
|
||||
//
|
||||
// It lives in the envelope's meta rather than in the business data because a
|
||||
// stop reason is not part of the resource: writing it into data both pollutes
|
||||
// the payload and forces the caller to tell an API field apart from one the CLI
|
||||
// synthesised. Complete plus NextToken is the whole story — a run either
|
||||
// exhausted the endpoint or stopped at --page-limit with somewhere to resume —
|
||||
// so there is no separate stop_reason string to keep in sync.
|
||||
type PaginationMeta struct {
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
NextToken string `json:"next_token,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
}
|
||||
|
||||
// PendingNotice, if set, returns system-level notices to inject as the
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
"table_with_safety_warning": {
|
||||
"stdout": "id name \n── ─────\n1 Alice\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"unknown_format_data_envelope_notice": {
|
||||
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
|
||||
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
rootcmd "github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -135,7 +134,6 @@ func commandDomain(c *cobra.Command, path string, source manifest.Source) string
|
||||
func flagFromPFlag(f *pflag.Flag) manifest.Flag {
|
||||
return manifest.Flag{
|
||||
Name: f.Name,
|
||||
Aliases: flagalias.Aliases(f),
|
||||
Shorthand: f.Shorthand,
|
||||
Usage: f.Usage,
|
||||
Hidden: f.Hidden,
|
||||
@@ -143,7 +141,7 @@ func flagFromPFlag(f *pflag.Flag) manifest.Flag {
|
||||
TakesValue: f.NoOptDefVal == "",
|
||||
DefValue: f.DefValue,
|
||||
NoOptValue: f.NoOptDefVal,
|
||||
Annotations: cloneAnnotations(f.Annotations, flagalias.AnnotationAliases),
|
||||
Annotations: cloneAnnotations(f.Annotations),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,23 +162,13 @@ func hasAnnotation(f *pflag.Flag, key string) bool {
|
||||
return ok && len(values) > 0
|
||||
}
|
||||
|
||||
func cloneAnnotations(in map[string][]string, excluded ...string) map[string][]string {
|
||||
func cloneAnnotations(in map[string][]string) map[string][]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
skip := make(map[string]struct{}, len(excluded))
|
||||
for _, key := range excluded {
|
||||
skip[key] = struct{}{}
|
||||
}
|
||||
out := make(map[string][]string, len(in))
|
||||
for key, values := range in {
|
||||
if _, ok := skip[key]; ok {
|
||||
continue
|
||||
}
|
||||
out[key] = append([]string(nil), values...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCommandFromCobraExportsAliasesAsFirstClassMetadata(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
cmd := &cobra.Command{Use: "+messages"}
|
||||
cmd.Flags().String("order", "desc", "message order")
|
||||
root.AddCommand(cmd)
|
||||
if err := flagalias.Bind(cmd, []flagalias.Spec{{Canonical: "order", Aliases: []string{"sort", "sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry := commandFromCobra(cmd, nil)
|
||||
flag := findFlag(entry.Flags, "order")
|
||||
if flag == nil {
|
||||
t.Fatal("manifest is missing canonical --order")
|
||||
}
|
||||
if !slices.Equal(flag.Aliases, []string{"sort", "sort-order"}) {
|
||||
t.Fatalf("manifest aliases = %v", flag.Aliases)
|
||||
}
|
||||
if _, leaked := flag.Annotations[flagalias.AnnotationAliases]; leaked {
|
||||
t.Fatalf("internal alias annotation leaked into manifest: %#v", flag.Annotations)
|
||||
}
|
||||
if findFlag(entry.Flags, "sort") != nil || findFlag(entry.Flags, "sort-order") != nil {
|
||||
t.Fatalf("aliases were exported as independent flags: %#v", entry.Flags)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
@@ -91,42 +90,6 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExportsShortcutAliasesOnCanonicalFlags(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectHandAuthored() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
command string
|
||||
canonical string
|
||||
aliases []string
|
||||
}{
|
||||
{command: "base +url-resolve", canonical: "url", aliases: []string{"query"}},
|
||||
{command: "im +chat-messages-list", canonical: "order", aliases: []string{"sort-order"}},
|
||||
{command: "sheets +workbook-info", canonical: "spreadsheet-token", aliases: []string{"token"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.command+"/"+test.canonical, func(t *testing.T) {
|
||||
cmd := findManifestCommand(&got, test.command)
|
||||
if cmd == nil {
|
||||
t.Fatalf("manifest command %q not found", test.command)
|
||||
}
|
||||
flag := findManifestFlag(cmd, test.canonical)
|
||||
if flag == nil {
|
||||
t.Fatalf("canonical --%s not found", test.canonical)
|
||||
}
|
||||
if strings.Join(flag.Aliases, ",") != strings.Join(test.aliases, ",") {
|
||||
t.Fatalf("aliases = %v, want %v", flag.Aliases, test.aliases)
|
||||
}
|
||||
for _, alias := range test.aliases {
|
||||
if findManifestFlag(cmd, alias) != nil {
|
||||
t.Fatalf("alias --%s exported as an independent flag", alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -19,57 +19,6 @@ func TestValidateRejectsDuplicateCommandPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsDistinctFlagAliases(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{{
|
||||
Path: "im +messages",
|
||||
CanonicalPath: "im +messages",
|
||||
Source: SourceShortcut,
|
||||
Flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort", "sort-order"}},
|
||||
{Name: "query", Aliases: []string{"keyword"}},
|
||||
},
|
||||
}}}
|
||||
if err := m.Validate(KindCommandManifest); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsFlagAliasCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []Flag
|
||||
}{
|
||||
{
|
||||
name: "alias and canonical",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"query"}},
|
||||
{Name: "query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alias and alias",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort"}},
|
||||
{Name: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alias self reference",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"order"}}},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{{
|
||||
Path: "im +messages", CanonicalPath: "im +messages", Source: SourceShortcut, Flags: test.flags,
|
||||
}}}
|
||||
if err := m.Validate(KindCommandManifest); err == nil {
|
||||
t.Fatal("expected alias collision to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidSource(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{
|
||||
{Path: "docs +fetch", CanonicalPath: "docs +fetch", Source: Source("invalid")},
|
||||
|
||||
@@ -40,7 +40,6 @@ type Command struct {
|
||||
|
||||
type Flag struct {
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
@@ -155,24 +154,15 @@ func validateCommand(kind string, i int, cmd Command) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
acceptedNames := make(map[string]string, len(cmd.Flags))
|
||||
seenFlags := make(map[string]struct{}, len(cmd.Flags))
|
||||
for j, flag := range cmd.Flags {
|
||||
if err := validateFlag(prefix, j, flag); err != nil {
|
||||
return err
|
||||
}
|
||||
if existing, ok := acceptedNames[flag.Name]; ok {
|
||||
if existing != flag.Name {
|
||||
return fmt.Errorf("%s flags[%d].name %s conflicts with an alias of --%s", prefix, j, flag.Name, existing)
|
||||
}
|
||||
if _, ok := seenFlags[flag.Name]; ok {
|
||||
return fmt.Errorf("%s flags[%d].name is duplicated: %s", prefix, j, flag.Name)
|
||||
}
|
||||
acceptedNames[flag.Name] = flag.Name
|
||||
for k, alias := range flag.Aliases {
|
||||
if existing, ok := acceptedNames[alias]; ok {
|
||||
return fmt.Errorf("%s flags[%d].aliases[%d] %s conflicts with accepted name of --%s", prefix, j, k, alias, existing)
|
||||
}
|
||||
acceptedNames[alias] = flag.Name
|
||||
}
|
||||
seenFlags[flag.Name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -185,29 +175,6 @@ func validateFlag(commandPrefix string, i int, flag Flag) error {
|
||||
if strings.ContainsAny(flag.Name, " \t\r\n") {
|
||||
return fmt.Errorf("%s.name must not contain whitespace", prefix)
|
||||
}
|
||||
seenAliases := make(map[string]struct{}, len(flag.Aliases))
|
||||
for j, alias := range flag.Aliases {
|
||||
aliasPrefix := fmt.Sprintf("%s.aliases[%d]", prefix, j)
|
||||
if err := validateString(aliasPrefix, alias, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(alias, "-") {
|
||||
return fmt.Errorf("%s must not include leading dashes", aliasPrefix)
|
||||
}
|
||||
if strings.ContainsAny(alias, " \t\r\n") {
|
||||
return fmt.Errorf("%s must not contain whitespace", aliasPrefix)
|
||||
}
|
||||
if strings.Contains(alias, "=") {
|
||||
return fmt.Errorf("%s must not contain '='", aliasPrefix)
|
||||
}
|
||||
if alias == flag.Name {
|
||||
return fmt.Errorf("%s must differ from canonical name %s", aliasPrefix, flag.Name)
|
||||
}
|
||||
if _, ok := seenAliases[alias]; ok {
|
||||
return fmt.Errorf("%s is duplicated: %s", aliasPrefix, alias)
|
||||
}
|
||||
seenAliases[alias] = struct{}{}
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value string
|
||||
|
||||
@@ -199,11 +199,7 @@ func materializePlaceholderExample(raw string, cmd manifest.Command) (materializ
|
||||
if eq := strings.IndexByte(name, '='); eq >= 0 {
|
||||
flagName := name[:eq]
|
||||
flag := findManifestFlag(&cmd, flagName)
|
||||
contextName := flagName
|
||||
if flag != nil {
|
||||
contextName = flag.Name
|
||||
}
|
||||
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(contextName, flag))
|
||||
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(flagName, flag))
|
||||
if !ok {
|
||||
return materializedExample{}, false
|
||||
}
|
||||
@@ -212,7 +208,7 @@ func materializePlaceholderExample(raw string, cmd manifest.Command) (materializ
|
||||
}
|
||||
flag := findManifestFlag(&cmd, name)
|
||||
if flag != nil && flag.TakesValue && i+1 < len(argv) {
|
||||
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(flag.Name, flag))
|
||||
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(name, flag))
|
||||
if !ok {
|
||||
return materializedExample{}, false
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package rules
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -171,11 +170,7 @@ func consumeFlags(args []string, cmd *manifest.Command) ([]string, []string, err
|
||||
hasInlineValue = true
|
||||
}
|
||||
flag := findManifestFlag(cmd, name)
|
||||
acceptedName := name
|
||||
if flag != nil {
|
||||
acceptedName = flag.Name
|
||||
}
|
||||
flags = append(flags, acceptedName)
|
||||
flags = append(flags, name)
|
||||
if flag != nil && !hasInlineValue && flag.TakesValue && i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
@@ -206,7 +201,7 @@ func isShellOperator(arg string) bool {
|
||||
|
||||
func findManifestFlag(cmd *manifest.Command, name string) *manifest.Flag {
|
||||
for i := range cmd.Flags {
|
||||
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name || slices.Contains(cmd.Flags[i].Aliases, name) {
|
||||
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name {
|
||||
return &cmd.Flags[i]
|
||||
}
|
||||
}
|
||||
@@ -246,9 +241,6 @@ func indexManifest(m manifest.Manifest) manifestIndex {
|
||||
flagSet := make(map[string]bool, len(cmd.Flags))
|
||||
for _, fl := range cmd.Flags {
|
||||
flagSet[fl.Name] = true
|
||||
for _, alias := range fl.Aliases {
|
||||
flagSet[alias] = true
|
||||
}
|
||||
}
|
||||
index.flags[cmd.Path] = flagSet
|
||||
}
|
||||
|
||||
@@ -379,26 +379,6 @@ func TestCheckReferencesAllowsHelpFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgainstManifestAcceptsAliasAndCanonicalizesFact(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +messages",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, TakesValue: true,
|
||||
}},
|
||||
}}}
|
||||
got, err := parseAgainstManifest(m, "lark-cli im +messages --sort-order asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got.Flags, ",") != "order" {
|
||||
t.Fatalf("flags = %v, want canonical order", got.Flags)
|
||||
}
|
||||
if index := indexManifest(m); !index.hasFlag("im +messages", "sort-order") {
|
||||
t.Fatal("manifest index did not retain accepted alias name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReferencesSkipsTemplateServicePlaceholder(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{Path: "im"}}}
|
||||
ex := skillscan.Example{Raw: "lark-cli im <resource> <method> [flags]", SourceFile: "skills/lark-demo/SKILL.md", Line: 1}
|
||||
|
||||
@@ -180,8 +180,8 @@ func saveCachedMerged(data []byte, cm CacheMeta) error {
|
||||
// localVersion is sent as data_version query param for server-side version comparison.
|
||||
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
|
||||
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
||||
// Remote metadata is platform traffic and must honor both the shared proxy
|
||||
// configuration and the registered platform transport extension.
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
@@ -42,28 +40,6 @@ func NewTransport(next http.RoundTripper, source Source) *Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// BaseRoundTripper exposes the network transport so policy routers can clone
|
||||
// and rebuild the complete decorator graph without dropping risk control.
|
||||
func (t *Transport) BaseRoundTripper() http.RoundTripper {
|
||||
if t == nil || t.next == nil {
|
||||
return internaltransport.Fallback()
|
||||
}
|
||||
return t.next
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper returns an equivalent risk-control boundary over base.
|
||||
func (t *Transport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
if t == nil {
|
||||
return NewTransport(base, nil)
|
||||
}
|
||||
cloned := *t
|
||||
if base == nil {
|
||||
base = internaltransport.Fallback()
|
||||
}
|
||||
cloned.next = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
||||
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), the LARK_CLI_NO_PROXY
|
||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
||||
//
|
||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestMatcher func(*http.Request) bool
|
||||
type transportPolicyBuilder func(http.RoundTripper) http.RoundTripper
|
||||
|
||||
type sdkBootstrapRedirectContextKey struct{}
|
||||
|
||||
var (
|
||||
// larkws pins this client during package initialization.
|
||||
sdkBootstrapHTTPClient = http.DefaultClient
|
||||
installDefaultClientMu sync.Mutex
|
||||
)
|
||||
|
||||
// sdkBootstrapTransport applies the platform HTTP policy only to dependency
|
||||
// bootstrap requests selected by match. Unmatched DefaultClient traffic is
|
||||
// delegated directly to the previous transport.
|
||||
type sdkBootstrapTransport struct {
|
||||
base http.RoundTripper
|
||||
match requestMatcher
|
||||
buildPlatformPolicy transportPolicyBuilder
|
||||
|
||||
policyMu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if !t.isBootstrapRequest(req) {
|
||||
return t.fallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
base := t.base
|
||||
if base == nil {
|
||||
// Resolve Shared lazily so bridge installation never initializes
|
||||
// workspace-scoped proxy state ahead of workspace selection.
|
||||
base = Shared()
|
||||
}
|
||||
buildPlatformPolicy := t.platformPolicyBuilder()
|
||||
if buildPlatformPolicy == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy is not configured",
|
||||
)
|
||||
}
|
||||
base = buildPlatformPolicy(base)
|
||||
if base == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy returned a nil transport",
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve extensions per hop so redirects retain platform policy.
|
||||
extended := WrapWithExtensionForClass(base, exttransport.RequestClassPlatform)
|
||||
guarded := &sameOriginRedirectTransport{base: extended}
|
||||
return guarded.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) platformPolicyBuilder() transportPolicyBuilder {
|
||||
t.policyMu.RLock()
|
||||
defer t.policyMu.RUnlock()
|
||||
return t.buildPlatformPolicy
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) setPlatformPolicyBuilder(build transportPolicyBuilder) {
|
||||
t.policyMu.Lock()
|
||||
t.buildPlatformPolicy = build
|
||||
t.policyMu.Unlock()
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) isBootstrapRequest(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
if _, redirected := req.Context().Value(sdkBootstrapRedirectContextKey{}).(struct{}); redirected {
|
||||
return true
|
||||
}
|
||||
return t.match != nil && t.match(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) fallbackTransport() http.RoundTripper {
|
||||
if t.base != nil {
|
||||
return t.base
|
||||
}
|
||||
// Preserve net/http's dynamic nil-Transport fallback.
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// sameOriginRedirectTransport rejects redirects before net/http can replay a
|
||||
// bootstrap request to a different logical origin.
|
||||
type sameOriginRedirectTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || !isFollowedRedirect(resp.StatusCode) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resp, nil
|
||||
}
|
||||
target, parseErr := req.URL.Parse(location)
|
||||
if parseErr != nil {
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"platform request returned an invalid redirect location: %v",
|
||||
parseErr,
|
||||
).WithCause(parseErr)
|
||||
}
|
||||
if sameOrigin(req.URL, target) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"platform bootstrap blocked cross-origin redirect from %q to %q",
|
||||
originName(req.URL),
|
||||
originName(target),
|
||||
)
|
||||
}
|
||||
|
||||
// sdkBootstrapRedirectPolicy preserves the prior hook and marks each redirect hop.
|
||||
func sdkBootstrapRedirectPolicy(
|
||||
match requestMatcher,
|
||||
previous func(*http.Request, []*http.Request) error,
|
||||
) func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if previous != nil {
|
||||
if err := previous(req, via); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(via) >= 10 {
|
||||
// Retain net/http's default redirect limit.
|
||||
return errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"stopped after 10 redirects",
|
||||
)
|
||||
}
|
||||
|
||||
if req == nil || len(via) == 0 || match == nil || !match(via[0]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), sdkBootstrapRedirectContextKey{}, struct{}{})
|
||||
*req = *req.WithContext(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func originName(candidate *url.URL) string {
|
||||
if candidate == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(candidate.Scheme) + "://" + candidate.Host
|
||||
}
|
||||
|
||||
func sameOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
originPort(left) == originPort(right)
|
||||
}
|
||||
|
||||
func originPort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isFollowedRedirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently,
|
||||
http.StatusFound,
|
||||
http.StatusSeeOther,
|
||||
http.StatusTemporaryRedirect,
|
||||
http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InstallSDKTransportBridge wraps larkws's captured HTTP bootstrap client. All
|
||||
// requests through that client hit the bridge, but only matched bootstrap
|
||||
// traffic uses platform policy. The SDK owns the subsequent WebSocket dial,
|
||||
// which does not use this net/http transport.
|
||||
func InstallSDKTransportBridge(buildPlatformPolicy func(http.RoundTripper) http.RoundTripper) {
|
||||
installDefaultClientMu.Lock()
|
||||
defer installDefaultClientMu.Unlock()
|
||||
installSDKTransportBridge(
|
||||
sdkBootstrapHTTPClient,
|
||||
isSDKWebSocketBootstrapRequest,
|
||||
buildPlatformPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
func isSDKWebSocketBootstrapRequest(req *http.Request) bool {
|
||||
return req != nil &&
|
||||
req.Method == http.MethodPost &&
|
||||
core.IsPlatformEndpointURL(req.URL) &&
|
||||
req.URL.Path == larkws.GenEndpointUri
|
||||
}
|
||||
|
||||
func installSDKTransportBridge(
|
||||
client *http.Client,
|
||||
match requestMatcher,
|
||||
buildPlatformPolicy transportPolicyBuilder,
|
||||
) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if existing, ok := client.Transport.(*sdkBootstrapTransport); ok {
|
||||
existing.setPlatformPolicyBuilder(buildPlatformPolicy)
|
||||
return
|
||||
}
|
||||
base := client.Transport
|
||||
previousRedirect := client.CheckRedirect
|
||||
client.Transport = &sdkBootstrapTransport{
|
||||
base: base,
|
||||
match: match,
|
||||
buildPlatformPolicy: buildPlatformPolicy,
|
||||
}
|
||||
client.CheckRedirect = sdkBootstrapRedirectPolicy(match, previousRedirect)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil)
|
||||
|
||||
type resolvedExtension struct {
|
||||
provider exttransport.Provider
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func resolveExtension() *resolvedExtension {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
interceptor := p.ResolveInterceptor(context.Background())
|
||||
if interceptor == nil {
|
||||
return nil
|
||||
}
|
||||
return &resolvedExtension{provider: p, interceptor: interceptor}
|
||||
}
|
||||
|
||||
func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
if e == nil {
|
||||
return base
|
||||
}
|
||||
if enforceScope {
|
||||
if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()}
|
||||
}
|
||||
|
||||
// ExtensionMiddleware wraps the built-in transport chain with extension
|
||||
// pre/post hooks. The built-in chain always executes unless an
|
||||
// exttransport.AbortableInterceptor rejects the request.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent an
|
||||
// extension from replacing cancellation, deadlines, or built-in values. The
|
||||
// request is cloned so URL and header mutations do not alter the caller's
|
||||
// request object. The body remains shared; interceptors that consume it must
|
||||
// restore it before returning.
|
||||
type ExtensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string
|
||||
}
|
||||
|
||||
// BaseRoundTripper returns the wrapped built-in transport chain.
|
||||
func (m *ExtensionMiddleware) BaseRoundTripper() http.RoundTripper {
|
||||
if m.Base == nil {
|
||||
return Shared()
|
||||
}
|
||||
return m.Base
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper clones the middleware over base.
|
||||
func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *m
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip invokes the extension pre hook, the wrapped transport, and then
|
||||
// the optional post hook. Abortable interceptors can stop the request before
|
||||
// the wrapped transport is called.
|
||||
func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortErr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortErr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortErr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortErr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortErr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx)
|
||||
resp, err := m.BaseRoundTripper().RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// WrapWithExtension wraps base with the currently registered transport
|
||||
// extension. With no registered provider or no resolved interceptor, base is
|
||||
// returned unchanged.
|
||||
func WrapWithExtension(base http.RoundTripper) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, "", false)
|
||||
}
|
||||
|
||||
// WrapWithExtensionForClass wraps base only when the registered provider
|
||||
// supports class. Providers without the optional ScopedProvider interface keep
|
||||
// their historical all-request behavior.
|
||||
func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, class, true)
|
||||
}
|
||||
@@ -1,924 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type testProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
resolveCalls *int
|
||||
}
|
||||
|
||||
func (p testProvider) Name() string { return "test-provider" }
|
||||
|
||||
func (p testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if p.resolveCalls != nil {
|
||||
*p.resolveCalls++
|
||||
}
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type scopedTestProvider struct {
|
||||
testProvider
|
||||
supported exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == p.supported
|
||||
}
|
||||
|
||||
type testHeaderInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Test-Platform", "routed")
|
||||
return nil
|
||||
}
|
||||
|
||||
type abortingTestInterceptor struct {
|
||||
reason error
|
||||
post func(*http.Response, error)
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
|
||||
panic("PreRoundTrip called for abortable interceptor")
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTripE(*http.Request) (func(*http.Response, error), error) {
|
||||
return i.post, i.reason
|
||||
}
|
||||
|
||||
func TestLegacyProviderKeepsAllRequestBehavior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
for _, client := range []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("external request header = %q, want routed for legacy provider", got)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want exactly 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedProviderOnlyRunsForSupportedRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clients := []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
}
|
||||
for _, client := range clients {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "" {
|
||||
t.Fatalf("external request received scoped provider header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) {
|
||||
resolveCalls := 0
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{
|
||||
interceptor: &testHeaderInterceptor{},
|
||||
resolveCalls: &resolveCalls,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
_ = NewHTTPPolicyRouter(base, base)
|
||||
|
||||
if resolveCalls != 1 {
|
||||
t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
var relayBody string
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/relay"), nil
|
||||
case "/relay":
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relayBody = string(body)
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusPermanentRedirect,
|
||||
"https://external.example/target",
|
||||
), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
const secret = "app_secret=secret"
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader(secret),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want cross-origin redirect rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if relayBody != secret {
|
||||
t.Fatalf("same-origin relay body = %q, want %q", relayBody, secret)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("cross-origin target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardClassifiesInvalidLocation(t *testing.T) {
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return redirectResponse(req, http.StatusFound, "%"), nil
|
||||
})
|
||||
client := &http.Client{Transport: &sameOriginRedirectTransport{base: base}}
|
||||
resp, err := client.Get("https://platform.example/bootstrap")
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid redirect location") {
|
||||
t.Fatalf("Do() error = %v, want invalid redirect rejection", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/invalid_response", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectPolicyInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectPolicyInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Extension-Hop", strconv.Itoa(i.calls))
|
||||
req.Header.Set("X-Reserved", "extension")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeRetainsPoliciesAcrossSameOriginRedirect(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectPolicyInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
var finalHeaders http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/next"), nil
|
||||
case "/next":
|
||||
finalHeaders = req.Header.Clone()
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
builtInCalls := 0
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(
|
||||
client,
|
||||
func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
},
|
||||
func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
builtInCalls++
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Builtin-Hop", strconv.Itoa(builtInCalls))
|
||||
req.Header.Set("X-Reserved", "trusted")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if finalHeaders == nil {
|
||||
t.Fatal("same-origin redirect target was not called")
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
if builtInCalls != 2 {
|
||||
t.Fatalf("built-in policy calls = %d, want 2", builtInCalls)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Extension-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Extension-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Builtin-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Builtin-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Reserved"); got != "trusted" {
|
||||
t.Fatalf("final X-Reserved = %q, want trusted built-in value", got)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectRewriteInterceptor struct {
|
||||
target *url.URL
|
||||
postLocation string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectRewriteInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.URL.Scheme = i.target.Scheme
|
||||
req.URL.Host = i.target.Host
|
||||
if i.postLocation == "" {
|
||||
return nil
|
||||
}
|
||||
return func(resp *http.Response, err error) {
|
||||
if err == nil && resp != nil && isFollowedRedirect(resp.StatusCode) {
|
||||
resp.Header.Set("Location", i.postLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing.T) {
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sidecarCalls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host != sidecarURL.Host {
|
||||
t.Fatalf("network host = %q, want extension target %q", req.URL.Host, sidecarURL.Host)
|
||||
}
|
||||
sidecarCalls++
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
case "/next":
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectRewriteInterceptor{target: sidecarURL}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if sidecarCalls != 2 {
|
||||
t.Fatalf("sidecar calls = %d, want 2", sidecarCalls)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: &redirectRewriteInterceptor{
|
||||
target: sidecarURL,
|
||||
postLocation: "https://external.example/target",
|
||||
}},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("secret"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want post-hook Location rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("post-hook redirect target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOriginNormalizesDefaultPort(t *testing.T) {
|
||||
left, err := url.Parse("https://platform.example/bootstrap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse("https://platform.example:443/next")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameOrigin(left, right) {
|
||||
t.Fatal("sameOrigin() = false for equivalent default HTTPS ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultClientBridgeCoversWebSocketSDKBootstrap(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seenHeader := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
seenHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"code":400,"msg":"stop after bootstrap"}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
installSDKTransportBridge(sdkBootstrapHTTPClient, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Host == strings.TrimPrefix(server.URL, "http://")
|
||||
}, identityTransportPolicy)
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithDomain(server.URL),
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := <-seenHeader; got != "routed" {
|
||||
t.Fatalf("WebSocket bootstrap header = %q, want routed", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1 bootstrap call", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUsesPinnedClientAfterGlobalReplacement(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
oldDefaultClient := http.DefaultClient
|
||||
t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
|
||||
|
||||
var pinnedCalls atomic.Int32
|
||||
pinnedHeader := make(chan string, 1)
|
||||
sdkBootstrapHTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
pinnedCalls.Add(1)
|
||||
pinnedHeader <- req.Header.Get("X-Pinned-Bridge")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":400,"msg":"stop"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
sdkBootstrapHTTPClient.CheckRedirect = nil
|
||||
|
||||
var replacementCalls atomic.Int32
|
||||
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
replacementCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Pinned-Bridge", "routed")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
})
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := pinnedCalls.Load(); got != 1 {
|
||||
t.Fatalf("SDK-pinned client calls = %d, want 1", got)
|
||||
}
|
||||
if got := <-pinnedHeader; got != "routed" {
|
||||
t.Fatalf("SDK-pinned bridge header = %q, want routed", got)
|
||||
}
|
||||
if got := replacementCalls.Load(); got != 0 {
|
||||
t.Fatalf("replacement DefaultClient calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKWebSocketBootstrapMatcherIsNarrow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "platform bootstrap",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "other platform path",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
{
|
||||
name: "wrong bootstrap method",
|
||||
method: http.MethodGet,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
},
|
||||
{
|
||||
name: "external lookalike",
|
||||
method: http.MethodPost,
|
||||
url: "https://external.example/callback/ws/endpoint",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tt.method, tt.url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := isSDKWebSocketBootstrapRequest(req); got != tt.want {
|
||||
t.Fatalf("isSDKWebSocketBootstrapRequest() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeLeavesOtherPlatformPathsUntouched(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
baseCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, isSDKWebSocketBootstrapRequest, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if baseCalls != 1 {
|
||||
t.Fatalf("base calls = %d, want 1", baseCalls)
|
||||
}
|
||||
if interceptor.calls != 0 {
|
||||
t.Fatalf("extension calls = %d, want 0 for unmatched DefaultClient traffic", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeNilBasePreservesDefaultTransportForUnmatchedRequest(t *testing.T) {
|
||||
oldDefaultTransport := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = oldDefaultTransport })
|
||||
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var firstCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
firstCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := &http.Client{}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return false }, nil)
|
||||
|
||||
var currentCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
currentCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/unmatched", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := firstCalls.Load(); got != 0 {
|
||||
t.Fatalf("install-time DefaultTransport calls = %d, want 0", got)
|
||||
}
|
||||
if got := currentCalls.Load(); got != 1 {
|
||||
t.Fatalf("request-time DefaultTransport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUpdatesPlatformPolicy(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return noContentResponse(req), nil
|
||||
})}
|
||||
var firstCalls, secondCalls int
|
||||
build := func(calls *int) transportPolicyBuilder {
|
||||
return func(base http.RoundTripper) http.RoundTripper {
|
||||
*calls++
|
||||
return base
|
||||
}
|
||||
}
|
||||
match := func(*http.Request) bool { return true }
|
||||
installSDKTransportBridge(client, match, build(&firstCalls))
|
||||
installSDKTransportBridge(client, match, build(&secondCalls))
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if firstCalls != 0 || secondCalls != 1 {
|
||||
t.Fatalf("policy calls = (%d, %d), want (0, 1)", firstCalls, secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedWithoutPlatformPolicy(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "policy is not configured") {
|
||||
t.Fatalf("Do() error = %v, want missing policy rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedForNilPlatformTransport(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, func(http.RoundTripper) http.RoundTripper {
|
||||
return nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "nil transport") {
|
||||
t.Fatalf("Do() error = %v, want nil policy transport rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectPolicyRetainsDefaultLimit(t *testing.T) {
|
||||
policy := sdkBootstrapRedirectPolicy(nil, nil)
|
||||
via := make([]*http.Request, 10)
|
||||
err := policy(&http.Request{}, via)
|
||||
if err == nil {
|
||||
t.Fatal("redirect policy error = nil after 10 redirects")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryNetwork ||
|
||||
problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("redirect problem = %#v, %v; want network/transport", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareUsesFallbackWhenBaseIsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
previous := http.DefaultTransport
|
||||
var calls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previous })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := (&ExtensionMiddleware{Ext: &testHeaderInterceptor{}}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("fallback transport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareAbortsBeforeBase(t *testing.T) {
|
||||
reason := errors.New("blocked")
|
||||
baseCalled := false
|
||||
postCalled := false
|
||||
interceptor := &abortingTestInterceptor{
|
||||
reason: reason,
|
||||
post: func(resp *http.Response, err error) {
|
||||
postCalled = true
|
||||
if resp != nil || err != reason {
|
||||
t.Errorf("post arguments = (%v, %v), want (nil, reason)", resp, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
middleware := &ExtensionMiddleware{
|
||||
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
baseCalled = true
|
||||
return nil, nil
|
||||
}),
|
||||
Ext: interceptor,
|
||||
ExtName: "test-provider",
|
||||
}
|
||||
|
||||
resp, err := middleware.RoundTrip(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %v, want nil", resp)
|
||||
}
|
||||
var abortErr *exttransport.AbortError
|
||||
if !errors.As(err, &abortErr) {
|
||||
t.Fatalf("error = %T, want *transport.AbortError", err)
|
||||
}
|
||||
if abortErr.Extension != "test-provider" || abortErr.Reason != reason {
|
||||
t.Fatalf("abort error = %#v, want provider and reason", abortErr)
|
||||
}
|
||||
if baseCalled {
|
||||
t.Fatal("base transport was called")
|
||||
}
|
||||
if !postCalled {
|
||||
t.Fatal("post hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveHTTPClientState(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
oldTransport := client.Transport
|
||||
oldCheckRedirect := client.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
client.Transport = oldTransport
|
||||
client.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
}
|
||||
|
||||
func identityTransportPolicy(base http.RoundTripper) http.RoundTripper {
|
||||
return base
|
||||
}
|
||||
|
||||
func redirectResponse(req *http.Request, status int, location string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Location": []string{location}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse(req *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestClassContextKey struct{}
|
||||
type forcedRequestClassContextKey struct{}
|
||||
|
||||
// HTTPPolicyRouter selects an HTTP transport policy from request intent and
|
||||
// the endpoint catalog. Explicit request intent takes precedence; otherwise
|
||||
// known platform endpoints use the platform policy and all other URLs use the
|
||||
// external policy.
|
||||
type HTTPPolicyRouter struct {
|
||||
platform http.RoundTripper
|
||||
external http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTripperDecorator describes a transport layer that can be rebuilt over
|
||||
// a cloned base transport. Connection-policy helpers use this contract to
|
||||
// preserve retry, response, and extension layers while safely customizing the
|
||||
// innermost *http.Transport.
|
||||
type RoundTripperDecorator interface {
|
||||
BaseRoundTripper() http.RoundTripper
|
||||
WithBaseRoundTripper(http.RoundTripper) http.RoundTripper
|
||||
}
|
||||
|
||||
// NewHTTPPolicyRouter constructs a router over two policy chains. A nil chain
|
||||
// falls back to the shared proxy-aware transport. The currently registered
|
||||
// extension provider is resolved once and applied according to its optional
|
||||
// ScopedProvider contract.
|
||||
func NewHTTPPolicyRouter(platform, external http.RoundTripper) *HTTPPolicyRouter {
|
||||
if platform == nil {
|
||||
platform = Shared()
|
||||
}
|
||||
if external == nil {
|
||||
external = Shared()
|
||||
}
|
||||
|
||||
extension := resolveExtension()
|
||||
return &HTTPPolicyRouter{
|
||||
platform: extension.wrap(platform, exttransport.RequestClassPlatform, true),
|
||||
external: extension.wrap(external, exttransport.RequestClassExternal, true),
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip dispatches the request to its selected policy chain.
|
||||
func (r *HTTPPolicyRouter) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"HTTP policy router received a nil request",
|
||||
)
|
||||
}
|
||||
class, err := classifyRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if class == exttransport.RequestClassPlatform {
|
||||
return r.platform.RoundTrip(req)
|
||||
}
|
||||
return r.external.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (r *HTTPPolicyRouter) transportForClass(class exttransport.RequestClass) (http.RoundTripper, bool) {
|
||||
switch class {
|
||||
case exttransport.RequestClassPlatform:
|
||||
return r.platform, true
|
||||
case exttransport.RequestClassExternal:
|
||||
return r.external, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func classifyRequest(req *http.Request) (exttransport.RequestClass, error) {
|
||||
if explicit, ok := req.Context().Value(requestClassContextKey{}).(exttransport.RequestClass); ok {
|
||||
switch explicit {
|
||||
case exttransport.RequestClassPlatform, exttransport.RequestClassExternal:
|
||||
return explicit, nil
|
||||
default:
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"unsupported HTTP request class %q",
|
||||
explicit,
|
||||
)
|
||||
}
|
||||
}
|
||||
if core.IsPlatformEndpointURL(req.URL) {
|
||||
return exttransport.RequestClassPlatform, nil
|
||||
}
|
||||
return exttransport.RequestClassExternal, nil
|
||||
}
|
||||
|
||||
// WithRequestClass returns a shallow copy of req with explicit routing intent.
|
||||
func WithRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withForcedRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
if _, forced := req.Context().Value(forcedRequestClassContextKey{}).(struct{}); forced {
|
||||
return req
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
ctx = context.WithValue(ctx, forcedRequestClassContextKey{}, struct{}{})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
type requestClassTransport struct {
|
||||
base http.RoundTripper
|
||||
class exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (t *requestClassTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.base.RoundTrip(withForcedRequestClass(req, t.class))
|
||||
}
|
||||
|
||||
// CloneHTTPTransport exposes a structural cloning capability without requiring
|
||||
// higher-level safety helpers to import this package. The explicit request
|
||||
// class selects the policy branch that must be rebuilt.
|
||||
func (t *requestClassTransport) CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool) {
|
||||
return CloneHTTPTransportForRequestClass(t.base, t.class)
|
||||
}
|
||||
|
||||
// TransformHTTPTransport clones the selected policy branch and replaces its
|
||||
// concrete transport in place. Keeping the replacement at the graph leaf is
|
||||
// important for policies that must observe requests after outer decorators
|
||||
// have run, such as proxy selection.
|
||||
func (t *requestClassTransport) TransformHTTPTransport(transform func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool) {
|
||||
return transformHTTPTransportForRequestClass(t.base, t.class, transform, 0)
|
||||
}
|
||||
|
||||
// ClientForRequestClass clones client and forces all of its requests through a
|
||||
// specific policy class. The original client is never mutated.
|
||||
func ClientForRequestClass(client *http.Client, class exttransport.RequestClass) *http.Client {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
cloned := *client
|
||||
base := client.Transport
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
cloned.Transport = &requestClassTransport{base: base, class: class}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// CloneHTTPTransportForRequestClass selects one policy branch, clones its
|
||||
// innermost *http.Transport, and rebuilds every composable decorator around
|
||||
// the clone. Callers can customize concrete before using rebuilt. The original
|
||||
// transport graph is never mutated.
|
||||
func CloneHTTPTransportForRequestClass(base http.RoundTripper, class exttransport.RequestClass) (rebuilt http.RoundTripper, concrete *http.Transport, ok bool) {
|
||||
rebuilt, ok = transformHTTPTransportForRequestClass(base, class, func(cloned *http.Transport) (http.RoundTripper, bool) {
|
||||
concrete = cloned
|
||||
return cloned, true
|
||||
}, 0)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return rebuilt, concrete, true
|
||||
}
|
||||
|
||||
func transformHTTPTransportForRequestClass(
|
||||
base http.RoundTripper,
|
||||
class exttransport.RequestClass,
|
||||
transform func(*http.Transport) (http.RoundTripper, bool),
|
||||
depth int,
|
||||
) (http.RoundTripper, bool) {
|
||||
if depth > 32 {
|
||||
return nil, false
|
||||
}
|
||||
if base == nil || transform == nil {
|
||||
if transform == nil {
|
||||
return nil, false
|
||||
}
|
||||
base = Shared()
|
||||
}
|
||||
|
||||
switch current := base.(type) {
|
||||
case *http.Transport:
|
||||
cloned := cloneHTTPTransport(current)
|
||||
rebuilt, valid := transform(cloned)
|
||||
return rebuilt, valid && rebuilt != nil
|
||||
case *requestClassTransport:
|
||||
return transformHTTPTransportForRequestClass(current.base, class, transform, depth+1)
|
||||
case *HTTPPolicyRouter:
|
||||
selected, valid := current.transportForClass(class)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
return transformHTTPTransportForRequestClass(selected, class, transform, depth+1)
|
||||
case RoundTripperDecorator:
|
||||
inner := current.BaseRoundTripper()
|
||||
if inner == nil || inner == base {
|
||||
return nil, false
|
||||
}
|
||||
rebuiltInner, valid := transformHTTPTransportForRequestClass(inner, class, transform, depth+1)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
rebuilt := current.WithBaseRoundTripper(rebuiltInner)
|
||||
return rebuilt, rebuilt != nil
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
// Clone leaves an auto-configured h2 handler on source.
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type cloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &cloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
type headerCloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Decorator", "applied")
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &headerCloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterClassifiesFromEndpointCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/open-apis/test",
|
||||
"https://example.com/file",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if platformCalls != 1 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 1 each", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterExplicitClassOverridesCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassExternal)
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 0 and 1", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOutermostIntentWins(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
resp, err := external.Get("https://open.feishu.cn/open-apis/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want outer external intent to win", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsInvalidExplicitClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClass("invalid"))
|
||||
if _, err := router.RoundTrip(req); err == nil || !strings.Contains(err.Error(), "unsupported HTTP request class") {
|
||||
t.Fatalf("RoundTrip() error = %v, want unsupported request class", err)
|
||||
} else if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsNilRequest(t *testing.T) {
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
_, err := router.RoundTrip(nil)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterReclassifiesRedirectTargets(t *testing.T) {
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
receivedHeader := make(chan string, 1)
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
receivedHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(external.Close)
|
||||
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusFound,
|
||||
Header: http.Header{"Location": []string{external.URL}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
http.DefaultTransport,
|
||||
)
|
||||
client := &http.Client{Transport: router}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := <-receivedHeader; got != "" {
|
||||
t.Fatalf("redirect target received platform-scoped header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want only the initial platform request", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassRebuildsDecorators(t *testing.T) {
|
||||
wantErr := errors.New("preserved proxy policy")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
decorated := &cloneTestDecorator{base: base}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
|
||||
rebuilt, concrete, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if concrete == base {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() reused the original *http.Transport")
|
||||
}
|
||||
if _, ok := rebuilt.(*cloneTestDecorator); !ok {
|
||||
t.Fatalf("rebuilt transport type = %T, want *cloneTestDecorator", rebuilt)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassPreservesAutomaticHTTP2(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
router := NewHTTPPolicyRouter(&http.Transport{}, source)
|
||||
|
||||
_, cloned, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if !cloned.ForceAttemptHTTP2 {
|
||||
t.Fatal("ForceAttemptHTTP2 = false, want true")
|
||||
}
|
||||
if cloned.TLSNextProto != nil {
|
||||
t.Fatal("TLSNextProto is non-nil, want automatic HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassKeepsOutermostIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := external.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no clone capability", external.Transport)
|
||||
}
|
||||
rebuilt, _, ok := source.CloneHTTPTransport()
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransport() ok = false")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want outer external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOverridesCallerIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassPlatform)
|
||||
|
||||
if _, err := client.Do(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("Do() error = %v, want forced external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformHTTPTransportReplacesLeafInsideDecorators(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
decorated := &headerCloneTestDecorator{base: &http.Transport{}}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := client.Transport.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no transform capability", client.Transport)
|
||||
}
|
||||
rebuilt, ok := source.TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool) {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if got := req.Header.Get("X-Decorator"); got != "applied" {
|
||||
t.Fatalf("leaf received X-Decorator = %q, want applied", got)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}), true
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("TransformHTTPTransport() ok = false")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := rebuilt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
@@ -57,29 +55,21 @@ func Fallback() *http.Transport {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns a policy-routed client over the shared proxy-aware
|
||||
// transport. Known platform endpoints use the platform request class; all
|
||||
// other URLs use the external request class. Existing unscoped transport
|
||||
// providers continue to apply to both classes.
|
||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
|
||||
// for outbound requests: a bare client falls back to http.DefaultTransport and
|
||||
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
|
||||
// fail-closed), creating an audit blind spot.
|
||||
//
|
||||
// A zero timeout means no client-level timeout (callers relying on context
|
||||
// deadlines pass 0).
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
base := Shared()
|
||||
return &http.Client{
|
||||
Transport: NewHTTPPolicyRouter(base, base),
|
||||
Transport: Shared(),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExternalHTTPClient returns a client for user-provided, pre-signed, CDN,
|
||||
// package-registry, and other non-platform URLs. It forces the external policy
|
||||
// while preserving the shared proxy configuration and the historical behavior
|
||||
// of unscoped transport providers. A zero timeout means no client-level timeout.
|
||||
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
return ClientForRequestClass(NewHTTPClient(timeout), exttransport.RequestClassExternal)
|
||||
}
|
||||
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
|
||||
// built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
|
||||
@@ -88,24 +88,23 @@ func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientConstructors verifies both the policy-routed client and its
|
||||
// forced-external view retain explicit transports and configured timeouts.
|
||||
func TestHTTPClientConstructors(t *testing.T) {
|
||||
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
||||
// transport (instead of a bare client that bypasses proxy plugin mode).
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
for name, client := range map[string]*http.Client{
|
||||
"routed": NewHTTPClient(7 * time.Second),
|
||||
"external": NewExternalHTTPClient(7 * time.Second),
|
||||
} {
|
||||
if client.Transport == nil {
|
||||
t.Fatalf("%s client transport is nil", name)
|
||||
}
|
||||
if client.Timeout != 7*time.Second {
|
||||
t.Errorf("%s client timeout = %v, want 7s", name, client.Timeout)
|
||||
}
|
||||
c := NewHTTPClient(7 * time.Second)
|
||||
if c.Transport == nil {
|
||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||
}
|
||||
if c.Transport != Shared() {
|
||||
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
|
||||
}
|
||||
if c.Timeout != 7*time.Second {
|
||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,32 +153,4 @@ func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
||||
}
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
client *http.Client
|
||||
url string
|
||||
}{
|
||||
"platform": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
"external": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
"forced external": {
|
||||
client: NewExternalHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp, err := test.client.Get(test.url)
|
||||
if err == nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
t.Fatalf("policy-routed client succeeded with malformed proxy config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,10 @@ func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return transport.NewExternalHTTPClient(fetchTimeout)
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,8 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
@@ -22,30 +19,6 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
type updateExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p updateExternalProvider) Name() string { return "update-external-test" }
|
||||
|
||||
func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type updateExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSkipEnv unsets all env vars that shouldSkip checks,
|
||||
// preventing the host environment (e.g. CI=true) from polluting test results.
|
||||
func clearSkipEnv(t *testing.T) {
|
||||
@@ -269,46 +242,6 @@ func TestRefreshCache(t *testing.T) {
|
||||
RefreshCache("1.0.0")
|
||||
}
|
||||
|
||||
func TestHTTPClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
previousClient := DefaultClient
|
||||
DefaultClient = nil
|
||||
t.Cleanup(func() { DefaultClient = previousClient })
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &updateExternalInterceptor{}
|
||||
exttransport.Register(updateExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingAtomicAccess(t *testing.T) {
|
||||
// Initially nil
|
||||
if got := GetPending(); got != nil {
|
||||
|
||||
@@ -5,15 +5,11 @@ package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,9 +34,6 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 0 { // RFC 1122 "this network"
|
||||
return true
|
||||
}
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
@@ -59,9 +52,6 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
|
||||
return true
|
||||
}
|
||||
if v4[0] >= 240 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() {
|
||||
@@ -86,42 +76,32 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are supported")
|
||||
}
|
||||
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
|
||||
return err
|
||||
}
|
||||
|
||||
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
|
||||
|
||||
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
|
||||
host := strings.TrimSpace(strings.ToLower(rawHost))
|
||||
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("URL host is required")
|
||||
return fmt.Errorf("URL host is required")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
return []net.IP{ip}, nil
|
||||
return nil
|
||||
}
|
||||
if lookupIP == nil {
|
||||
lookupIP = net.DefaultResolver.LookupIP
|
||||
}
|
||||
ips, err := lookupIP(ctx, "ip", host)
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
|
||||
@@ -135,10 +115,7 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
cloned.Transport = &downloadSchemeTransport{
|
||||
base: cloneDownloadTransport(base.Transport),
|
||||
allowHTTP: opts.AllowHTTP,
|
||||
}
|
||||
cloned.Transport = cloneDownloadTransport(base.Transport)
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= opts.MaxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
@@ -161,310 +138,18 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type downloadSchemeTransport struct {
|
||||
base http.RoundTripper
|
||||
allowHTTP bool
|
||||
}
|
||||
|
||||
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"download transport received a nil request",
|
||||
)
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(req.URL.Scheme, "https"):
|
||||
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
|
||||
default:
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"only https URLs are supported",
|
||||
)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
type selectedDownloadProxyKey struct{}
|
||||
|
||||
type proxyAwareDownloadTransport struct {
|
||||
selectProxy func(*http.Request) (*url.URL, error)
|
||||
direct http.RoundTripper
|
||||
proxied *http.Transport
|
||||
lookupIP downloadLookupIPFunc
|
||||
|
||||
mu sync.Mutex
|
||||
proxiedByTLSServer map[string]*http.Transport
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download transport received a nil request")
|
||||
}
|
||||
proxyURL, err := t.selectProxy(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proxyURL == nil {
|
||||
return t.direct.RoundTrip(req)
|
||||
}
|
||||
|
||||
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
|
||||
if err != nil {
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
|
||||
// HTTP proxies cannot pin the target IP separately from the Host header.
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"plain HTTP hostname downloads through a proxy are not allowed",
|
||||
).WithHint("use HTTPS or a literal public IP")
|
||||
}
|
||||
|
||||
selected := *proxyURL
|
||||
proxied := t.proxied
|
||||
if strings.EqualFold(req.URL.Scheme, "https") {
|
||||
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for index, targetIP := range targetIPs {
|
||||
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
|
||||
if pinErr != nil {
|
||||
return nil, pinErr
|
||||
}
|
||||
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
|
||||
proxiedReq = proxiedReq.WithContext(ctx)
|
||||
|
||||
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
|
||||
if roundTripErr == nil {
|
||||
if resp != nil {
|
||||
// Hide the internal pinned URL from redirect handling.
|
||||
resp.Request = req
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = roundTripErr
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if req.Context().Err() != nil {
|
||||
break
|
||||
}
|
||||
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
|
||||
break
|
||||
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else {
|
||||
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
|
||||
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
|
||||
closer.CloseIdleConnections()
|
||||
}
|
||||
t.proxied.CloseIdleConnections()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, transport := range t.proxiedByTLSServer {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
|
||||
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
|
||||
serverName = configured.ServerName
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
|
||||
return transport
|
||||
}
|
||||
|
||||
transport := t.proxied.Clone()
|
||||
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
|
||||
targetTLSConfig.ServerName = serverName
|
||||
transport.TLSClientConfig = targetTLSConfig
|
||||
configureHTTPSProxyTLSDialer(transport, t.proxied)
|
||||
if t.proxiedByTLSServer == nil {
|
||||
t.proxiedByTLSServer = make(map[string]*http.Transport)
|
||||
}
|
||||
t.proxiedByTLSServer[serverName] = transport
|
||||
return transport
|
||||
}
|
||||
|
||||
type blockedDownloadTransport struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, t.err
|
||||
}
|
||||
|
||||
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if source, ok := base.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
}); ok {
|
||||
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
if source, ok := base.(*http.Transport); ok && source != nil {
|
||||
rebuilt, transformed := newDownloadTransportLeaf(source)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
return &blockedDownloadTransport{err: errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"cannot safely clone download transport %T",
|
||||
base,
|
||||
)}
|
||||
}
|
||||
|
||||
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
|
||||
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
|
||||
}
|
||||
|
||||
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
|
||||
if source == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
selectProxy := source.Proxy
|
||||
direct := cloneDownloadHTTPTransport(source)
|
||||
direct.Proxy = nil
|
||||
configureDirectDownloadTransport(direct)
|
||||
if selectProxy == nil {
|
||||
return direct, true
|
||||
}
|
||||
|
||||
// The proxied branch validates the requested URL before construction and
|
||||
// on every redirect. Its TCP peer is the selected proxy, so applying the
|
||||
// direct-origin IP guard there would incorrectly reject trusted loopback or
|
||||
// private-network proxies. Freeze the selected proxy in request context so
|
||||
// a stateful selector cannot switch the second lookup to direct egress.
|
||||
proxied := cloneDownloadHTTPTransport(source)
|
||||
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
|
||||
if !ok || selected == nil {
|
||||
return nil, fmt.Errorf("download proxy selection is missing")
|
||||
}
|
||||
cloned := *selected
|
||||
return &cloned, nil
|
||||
}
|
||||
return &proxyAwareDownloadTransport{
|
||||
selectProxy: selectProxy,
|
||||
direct: direct,
|
||||
proxied: proxied,
|
||||
lookupIP: lookupIP,
|
||||
proxiedByTLSServer: make(map[string]*http.Transport),
|
||||
}, true
|
||||
}
|
||||
|
||||
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download request URL is missing")
|
||||
}
|
||||
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
|
||||
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
|
||||
}
|
||||
|
||||
originalHost := req.URL.Host
|
||||
pinnedHost := targetIP.String()
|
||||
if port := req.URL.Port(); port != "" {
|
||||
pinnedHost = net.JoinHostPort(pinnedHost, port)
|
||||
} else if strings.Contains(pinnedHost, ":") {
|
||||
pinnedHost = "[" + pinnedHost + "]"
|
||||
}
|
||||
|
||||
pinned := req.Clone(req.Context())
|
||||
pinnedURL := *req.URL
|
||||
pinnedURL.Host = pinnedHost
|
||||
pinned.URL = &pinnedURL
|
||||
pinned.Host = originalHost
|
||||
return pinned, nil
|
||||
}
|
||||
|
||||
func canRetryDownloadTarget(req *http.Request) bool {
|
||||
if req == nil || req.Body != nil {
|
||||
return false
|
||||
}
|
||||
return req.Method == http.MethodGet || req.Method == http.MethodHead
|
||||
}
|
||||
|
||||
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
|
||||
if config == nil {
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return config.Clone()
|
||||
}
|
||||
|
||||
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
|
||||
if transport.DialTLSContext != nil || transport.DialTLS != nil {
|
||||
return
|
||||
}
|
||||
|
||||
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
|
||||
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := proxyTLSConfig.Clone()
|
||||
serverName, _, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
|
||||
}
|
||||
config.ServerName = serverName
|
||||
tlsConn := tls.Client(rawConn, config)
|
||||
handshakeCtx := ctx
|
||||
cancel := func() {}
|
||||
if source.TLSHandshakeTimeout > 0 {
|
||||
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
||||
rawConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
|
||||
if source.DialContext != nil {
|
||||
return source.DialContext(ctx, network, addr)
|
||||
}
|
||||
if source.Dial != nil {
|
||||
return source.Dial(network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialConn(ctx, origDial, network, addr)
|
||||
@@ -473,7 +158,7 @@ func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -487,26 +172,13 @@ func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
if cloned.DialTLS != nil {
|
||||
origDialTLS := cloned.DialTLS
|
||||
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
|
||||
conn, err := origDialTLS(network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
||||
@@ -522,7 +194,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -536,14 +208,6 @@ func dialConn(ctx context.Context, dialFn func(context.Context, string, string)
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func downloadTargetPolicyError(err error) error {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
func validateConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return fmt.Errorf("nil connection")
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestProxiedHTTPSDownloadPinsValidatedTargetIP(t *testing.T) {
|
||||
const (
|
||||
targetHost = "rebind.example"
|
||||
targetIP = "203.0.113.10"
|
||||
)
|
||||
|
||||
proxyCalled := make(chan struct{}, 1)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
proxyCalled <- struct{}{}
|
||||
if req.Method != http.MethodConnect {
|
||||
t.Errorf("proxy request method = %q, want CONNECT", req.Method)
|
||||
}
|
||||
if got := req.Host; got != targetIP+":443" {
|
||||
t.Errorf("proxy CONNECT target = %q, want validated IP %q", got, targetIP+":443")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP(targetIP)}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://"+targetHost+"/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned, err := pinDownloadRequestTargetToIP(req, net.ParseIP(targetIP))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pinned.Host != targetHost {
|
||||
t.Fatalf("pinned request Host = %q, want %q", pinned.Host, targetHost)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection after CONNECT")
|
||||
}
|
||||
select {
|
||||
case <-proxyCalled:
|
||||
default:
|
||||
t.Fatal("proxy was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedDownloadIPBlocksReservedIPv4(t *testing.T) {
|
||||
for _, rawIP := range []string{"0.1.2.3", "240.0.0.1"} {
|
||||
if !isRestrictedDownloadIP(net.ParseIP(rawIP)) {
|
||||
t.Fatalf("%s was classified as safe", rawIP)
|
||||
}
|
||||
}
|
||||
if isRestrictedDownloadIP(net.ParseIP("1.1.1.1")) {
|
||||
t.Fatal("1.1.1.1 was classified as restricted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTLSConfigSetsMinimumVersion(t *testing.T) {
|
||||
if got := cloneDownloadTLSConfig(nil).MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %d, want TLS 1.2", got)
|
||||
}
|
||||
configured := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
if got := cloneDownloadTLSConfig(configured).MinVersion; got != tls.VersionTLS13 {
|
||||
t.Fatalf("cloned MinVersion = %d, want TLS 1.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadHTTPTransportPreservesHTTP2Policy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source *http.Transport
|
||||
wantForce bool
|
||||
wantH2Handler bool
|
||||
wantProtocolMap bool
|
||||
}{
|
||||
{
|
||||
name: "automatic",
|
||||
source: &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "custom TLS without opt-in",
|
||||
source: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom dial without opt-in",
|
||||
source: &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, errors.New("unused")
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit opt-in",
|
||||
source: &http.Transport{ForceAttemptHTTP2: true},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "explicit h2 handler",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{
|
||||
"h2": func(string, *tls.Conn) http.RoundTripper { return nil },
|
||||
},
|
||||
},
|
||||
wantH2Handler: true,
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
{
|
||||
name: "explicit opt-out",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||
},
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cloned := cloneDownloadHTTPTransport(test.source)
|
||||
if cloned.ForceAttemptHTTP2 != test.wantForce {
|
||||
t.Fatalf("ForceAttemptHTTP2 = %v, want %v", cloned.ForceAttemptHTTP2, test.wantForce)
|
||||
}
|
||||
_, hasH2Handler := cloned.TLSNextProto["h2"]
|
||||
if hasH2Handler != test.wantH2Handler {
|
||||
t.Fatalf("h2 handler = %v, want %v", hasH2Handler, test.wantH2Handler)
|
||||
}
|
||||
if hasProtocolMap := cloned.TLSNextProto != nil; hasProtocolMap != test.wantProtocolMap {
|
||||
t.Fatalf("TLSNextProto is non-nil = %v, want %v", hasProtocolMap, test.wantProtocolMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTransportPreservesAutomaticHTTP2(t *testing.T) {
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
rebuilt := cloneDownloadTransport(source)
|
||||
proxyAware, ok := rebuilt.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", rebuilt)
|
||||
}
|
||||
direct, ok := proxyAware.direct.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("direct transport type = %T, want *http.Transport", proxyAware.direct)
|
||||
}
|
||||
for name, transport := range map[string]*http.Transport{
|
||||
"direct": direct,
|
||||
"proxied": proxyAware.proxied,
|
||||
} {
|
||||
if !transport.ForceAttemptHTTP2 {
|
||||
t.Fatalf("%s ForceAttemptHTTP2 = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedDownloadRejectsRestrictedResolvedTarget(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://rebind.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want restricted target rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a restricted resolved target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedPlainHTTPHostnameRejectsLocalProxy(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(ctx context.Context, _, host string) ([]net.IP, error) {
|
||||
if host == "public.example" {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
}
|
||||
return net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want plain HTTP hostname rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
} else if problem.Hint != "use HTTPS or a literal public IP" {
|
||||
t.Fatalf("RoundTrip() hint = %q, want recovery guidance", problem.Hint)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a plain HTTP hostname target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSDownloadTriesEveryValidatedTargetIP(t *testing.T) {
|
||||
connectTargets := make(chan string, 2)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
connectTargets <- req.Host
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{
|
||||
net.ParseIP("203.0.113.10"),
|
||||
net.ParseIP("203.0.113.11"),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://multi.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection")
|
||||
}
|
||||
for _, want := range []string{"203.0.113.10:443", "203.0.113.11:443"} {
|
||||
select {
|
||||
case got := <-connectTargets:
|
||||
if got != want {
|
||||
t.Fatalf("proxy CONNECT target = %q, want %q", got, want)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("proxy did not receive CONNECT target %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanRetryDownloadTargetOnlyAllowsBodylessReads(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{method: http.MethodGet, want: true},
|
||||
{method: http.MethodHead, want: true},
|
||||
{method: http.MethodPost},
|
||||
{method: http.MethodGet, body: "body"},
|
||||
} {
|
||||
req, err := http.NewRequest(test.method, "https://download.example/file", strings.NewReader(test.body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.body == "" {
|
||||
req.Body = nil
|
||||
}
|
||||
if got := canRetryDownloadTarget(req); got != test.want {
|
||||
t.Fatalf("canRetryDownloadTarget(%s, body=%q) = %v, want %v", test.method, test.body, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSTargetPreservesOriginalTLSServerName(t *testing.T) {
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"})},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
proxyAware, ok := transport.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", transport)
|
||||
}
|
||||
|
||||
pinned := proxyAware.proxiedTransportForTLSServer("download.example")
|
||||
if pinned.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil")
|
||||
}
|
||||
if pinned.TLSClientConfig.ServerName != "download.example" {
|
||||
t.Fatalf("TLS ServerName = %q, want download.example", pinned.TLSClientConfig.ServerName)
|
||||
}
|
||||
if proxyAware.proxied.TLSClientConfig != nil && proxyAware.proxied.TLSClientConfig.ServerName != "" {
|
||||
t.Fatalf("base proxy TLS ServerName = %q, want unchanged", proxyAware.proxied.TLSClientConfig.ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesLegacyDial(t *testing.T) {
|
||||
wantErr := errors.New("legacy dial used")
|
||||
source := &http.Transport{
|
||||
Dial: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
if target.DialTLSContext == nil {
|
||||
t.Fatal("DialTLSContext = nil")
|
||||
}
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLSContext() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSClosesRestrictedConnection(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() { serverConn.Close() })
|
||||
conn := &trackedDownloadConn{
|
||||
Conn: clientConn,
|
||||
remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443},
|
||||
}
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
_, err := transport.DialTLS("tcp", "public.example:443")
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("DialTLS() error = %v, want restricted target rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DialTLS() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !conn.closed {
|
||||
t.Fatal("restricted connection was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSPreservesDialError(t *testing.T) {
|
||||
wantErr := errors.New("dial failed")
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
if _, err := transport.DialTLS("tcp", "public.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLS() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerRetainsHandshakeTimeout(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSHandshakeTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
started := time.Now()
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); err == nil {
|
||||
t.Fatal("DialTLSContext() error = nil, want TLS handshake timeout")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("TLS handshake timeout took %s, want under 1s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesProxyServerName(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
|
||||
proxySNI := make(chan string, 1)
|
||||
serverTLSConfig := server.TLS.Clone()
|
||||
serverTLSConfig.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
proxySNI <- info.ServerName
|
||||
return nil, nil
|
||||
}
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- tls.Server(serverConn, serverTLSConfig).Handshake()
|
||||
}()
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(server.Certificate())
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "target.example.com",
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
conn, err := target.DialTLSContext(context.Background(), "tcp", "example.com:443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if err := <-serverErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-proxySNI; got != "example.com" {
|
||||
t.Fatalf("proxy TLS ServerName = %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
type trackedDownloadConn struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) Close() error {
|
||||
c.closed = true
|
||||
return c.Conn.Close()
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type opaqueRoundTripper struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *opaqueRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.called = true
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
type downloadTestProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p downloadTestProvider) Name() string { return "download-test" }
|
||||
|
||||
func (p downloadTestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type downloadHeaderInterceptor struct{}
|
||||
|
||||
func (downloadHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set("X-Use-Proxy", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientPreservesPolicyRouterBaseTransport(t *testing.T) {
|
||||
wantErr := errors.New("proxy policy blocked request")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Transport.RoundTrip(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want preserved proxy error %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientRejectsInitialHTTPBeforeTransport(t *testing.T) {
|
||||
base := &opaqueRoundTripper{}
|
||||
download := validate.NewDownloadHTTPClient(
|
||||
&http.Client{Transport: base},
|
||||
validate.DownloadHTTPClientOptions{},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want initial HTTP rejection")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if base.called {
|
||||
t.Fatal("base transport was called for a disallowed initial HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientAllowsSelectedLoopbackProxy(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Host != "203.0.113.10" {
|
||||
t.Errorf("proxy request target = %q, want 203.0.113.10", req.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through selected loopback proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientSelectsProxyAfterOuterDecorators(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if got := req.Header.Get("X-Use-Proxy"); got != "1" {
|
||||
t.Errorf("proxy received X-Use-Proxy = %q, want 1", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("proxy selector ran before decorators")
|
||||
base := &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) {
|
||||
if req.Header.Get("X-Use-Proxy") != "1" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return proxyURL, nil
|
||||
}}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(downloadTestProvider{interceptor: downloadHeaderInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through decorator-selected proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientGuardsLegacyDialTLS(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
|
||||
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
|
||||
}}
|
||||
download := validate.NewDownloadHTTPClient(&http.Client{Transport: base}, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
|
||||
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
|
||||
opaque := &opaqueRoundTripper{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: opaque},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
|
||||
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if opaque.called {
|
||||
t.Fatal("opaque transport was called after safe cloning failed")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ lint/
|
||||
├── main.go # package main — dispatches to every registered domain
|
||||
├── lintapi/ # shared types every domain returns
|
||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||
├── errscontract/ # first domain: typed-error contract guards
|
||||
└── errscontract/ # first domain: typed-error contract guards
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── runner.go
|
||||
├── typecheck.go
|
||||
@@ -30,27 +30,14 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
├── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
└── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── unapproved.go # Go AST/type-aware hostname extraction
|
||||
├── policy.go # exact public/fixture allowlist validation
|
||||
├── diff.go # added-line attribution
|
||||
└── *_test.go
|
||||
└── flagcontract/ # framework ownership for flag aliases
|
||||
├── scan.go # rejects local name normalizers and independent aliases
|
||||
└── scan_test.go
|
||||
```
|
||||
|
||||
## Flag alias contract (`flagcontract`)
|
||||
|
||||
`flagcontract` keeps exact flag-name synonyms on the shared framework path. It
|
||||
rejects production calls to `SetNormalizeFunc` outside `internal/flagalias` and
|
||||
independent hidden flags described as aliases. Exact synonyms belong in the
|
||||
canonical `common.Flag.Aliases`; legacy inputs with a different value grammar
|
||||
or meaning remain real hidden flags and normalize into canonical state
|
||||
inside the business-owned `Shortcut.Normalize` execution stage. Exact
|
||||
aliases always share the canonical flag's occurrence and conflict semantics.
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
`domaincontract` contains two complementary Go source guards.
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package flagcontract keeps flag aliases on the shared framework path.
|
||||
package flagcontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
const aliasOwnerPath = "internal/flagalias/flagalias.go"
|
||||
|
||||
// ScanOptions mirrors the aggregate lint runner's incremental interface. The
|
||||
// alias rules are repository invariants and intentionally scan all production
|
||||
// Go files; ChangedFrom is retained for a uniform caller contract.
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
func ScanRepoWithOptions(root string, _ ScanOptions) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
switch entry.Name() {
|
||||
case ".git", ".claude", "vendor", "node_modules", "testdata":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil // another compiler/lint stage owns syntax errors
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
switch value := node.(type) {
|
||||
case *ast.CallExpr:
|
||||
selector, ok := value.Fun.(*ast.SelectorExpr)
|
||||
if ok && selector.Sel.Name == "SetNormalizeFunc" && rel != aliasOwnerPath {
|
||||
out = append(out, violation(fset, rel, value.Pos(),
|
||||
"flag_alias_normalizer_owner",
|
||||
"SetNormalizeFunc is owned by internal/flagalias",
|
||||
"declare exact synonyms with common.Flag.Aliases or call flagalias.Bind from a framework adapter"))
|
||||
}
|
||||
case *ast.CompositeLit:
|
||||
if name, desc, hidden := hiddenFlagLiteral(value); hidden && aliasDescription(desc) {
|
||||
out = append(out, violation(fset, rel, value.Pos(),
|
||||
"flag_alias_independent_flag",
|
||||
"--"+name+" is modeled as an independent hidden alias",
|
||||
"put an exact synonym in the canonical common.Flag.Aliases; use Shortcut.Normalize only when the legacy input's value grammar or meaning differs"))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func violation(fset *token.FileSet, file string, pos token.Pos, rule, message, suggestion string) lintapi.Violation {
|
||||
return lintapi.Violation{
|
||||
Rule: rule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: file,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: message,
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
func hiddenFlagLiteral(lit *ast.CompositeLit) (name, desc string, hidden bool) {
|
||||
for _, element := range lit.Elts {
|
||||
item, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := item.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key.Name {
|
||||
case "Name":
|
||||
name, _ = stringLiteral(item.Value)
|
||||
case "Desc":
|
||||
desc, _ = stringLiteral(item.Value)
|
||||
case "Hidden":
|
||||
ident, ok := item.Value.(*ast.Ident)
|
||||
hidden = ok && ident.Name == "true"
|
||||
}
|
||||
}
|
||||
return name, desc, hidden && name != ""
|
||||
}
|
||||
|
||||
func stringLiteral(expr ast.Expr) (string, bool) {
|
||||
lit, ok := expr.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(lit.Value)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func aliasDescription(desc string) bool {
|
||||
desc = strings.ToLower(desc)
|
||||
return strings.Contains(desc, "alias for --") ||
|
||||
strings.Contains(desc, "alias of --") ||
|
||||
strings.Contains(desc, "hidden alias")
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package flagcontract
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanRejectsLocalNormalizerAndIndependentAliasFlag(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFixture(t, root, "shortcuts/demo/demo.go", `package demo
|
||||
func mount(cmd interface{ SetNormalizeFunc(any) }) { cmd.SetNormalizeFunc(nil) }
|
||||
var flags = []struct { Name, Desc string; Hidden bool }{
|
||||
{Name: "sort-order", Hidden: true, Desc: "hidden alias for --order"},
|
||||
{Name: "legacy-sort", Hidden: true, Desc: "legacy vocabulary normalized to --order"},
|
||||
}
|
||||
`)
|
||||
writeFixture(t, root, "shortcuts/demo/demo_test.go", `package demo
|
||||
func ignored(cmd interface{ SetNormalizeFunc(any) }) { cmd.SetNormalizeFunc(nil) }
|
||||
`)
|
||||
writeFixture(t, root, aliasOwnerPath, `package flagalias
|
||||
func bind(cmd interface{ SetNormalizeFunc(any) }) { cmd.SetNormalizeFunc(nil) }
|
||||
`)
|
||||
|
||||
got, err := ScanRepoWithOptions(root, ScanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("violations = %#v, want 2", got)
|
||||
}
|
||||
if got[0].Rule != "flag_alias_normalizer_owner" || got[1].Rule != "flag_alias_independent_flag" {
|
||||
t.Fatalf("rules = %q, %q", got[0].Rule, got[1].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCurrentRepositoryHasNoViolations(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ScanRepoWithOptions(root, ScanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("flag contract violations: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixture(t *testing.T, root, rel, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/lint/domaincontract"
|
||||
"github.com/larksuite/cli/lint/errscontract"
|
||||
"github.com/larksuite/cli/lint/flagcontract"
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
@@ -49,11 +48,6 @@ var scanners = []scanner{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
{name: "flagcontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return flagcontract.ScanRepoWithOptions(root, flagcontract.ScanOptions{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -75,9 +74,11 @@ func normalizeTimestamp(raw string) (string, error) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
|
||||
}
|
||||
|
||||
//nolint:forbidigo // Presigned transfers use the external HTTP policy.
|
||||
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。
|
||||
//
|
||||
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
|
||||
func newFileTransferClient() *http.Client {
|
||||
return transport.NewExternalHTTPClient(0)
|
||||
return &http.Client{Transport: http.DefaultTransport}
|
||||
}
|
||||
|
||||
// URL helpers for the file (storage) CLI commands.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type appsExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p appsExternalProvider) Name() string { return "apps-external-test" }
|
||||
|
||||
func (p appsExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (appsExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type appsExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *appsExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
type appsRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f appsRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestFileTransferClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &appsExternalInterceptor{}
|
||||
exttransport.Register(appsExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = appsRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/presigned/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, map[string]int{"offset": -1, "limit": 100})
|
||||
assertDryRunContains(t, dryRunTableList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables", "offset=0", "limit=100")
|
||||
|
||||
pageSizeAliasRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, map[string]int{"page-size": 40})
|
||||
assertDryRunContains(t, dryRunTableList(ctx, pageSizeAliasRT), "limit=40")
|
||||
|
||||
rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables")
|
||||
@@ -216,6 +219,18 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
`"sort":[{"desc":true,"field":"Updated At"}]`,
|
||||
)
|
||||
|
||||
searchPageSizeAliasRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"keyword": "Alice",
|
||||
},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
map[string]int{"page-size": 25},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordSearch(ctx, searchPageSizeAliasRT), `"limit":25`)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
|
||||
@@ -2009,16 +2009,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search json conflict reports canonical pagination flag", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--limit", "10", "--page-size", "201",
|
||||
}, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--limit"}, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -28,23 +27,19 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
|
||||
"Each new question creates a field in the form's table; question IDs are field IDs.",
|
||||
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id")).
|
||||
Body(map[string]interface{}{"questions": questions})
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
@@ -52,9 +47,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
formId := runtime.Str("form-id")
|
||||
questionsJSON := runtime.Str("questions")
|
||||
|
||||
questions, err := parseFormQuestionsCreate(questionsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
@@ -83,31 +78,3 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
|
||||
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
if questions == nil {
|
||||
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
|
||||
}
|
||||
if len(questions) > 10 {
|
||||
return nil, baseValidationErrorf("--questions must contain at most 10 items")
|
||||
}
|
||||
for i, question := range questions {
|
||||
item, ok := question.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
|
||||
}
|
||||
title, ok := item["title"].(string)
|
||||
if !ok || strings.TrimSpace(title) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
|
||||
}
|
||||
questionType, ok := item["type"].(string)
|
||||
if !ok || strings.TrimSpace(questionType) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
|
||||
}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
|
||||
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"+form-questions-list",
|
||||
"verified empty form can create directly",
|
||||
"question IDs are field IDs",
|
||||
"explicitly requests a separate same-title question",
|
||||
"+form-questions-update",
|
||||
} {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ var BaseURLResolve = common.Shortcut{
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Aliases: []string{"query"}, Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
|
||||
@@ -107,7 +108,9 @@ var BaseTitleResolve = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Aliases: []string{"query", "url"}, Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
@@ -132,7 +135,15 @@ var BaseTitleResolve = common.Shortcut{
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
value := strings.TrimSpace(runtime.Str("url"))
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
@@ -140,7 +151,25 @@ func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
pickedValue := strings.TrimSpace(runtime.Str("title"))
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
|
||||
@@ -497,30 +497,24 @@ func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveAliasesUseCanonicalRepeatedFlagSemantics(t *testing.T) {
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user", "--dry-run",
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "bas2") || strings.Contains(got, "bas1") {
|
||||
t.Fatalf("alias should be the last occurrence: %s", got)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user", "--dry-run",
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "Sales") || strings.Contains(got, "Pipeline") {
|
||||
t.Fatalf("alias should be the last occurrence: %s", got)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -562,8 +556,8 @@ func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias != primary {
|
||||
t.Fatalf("Lookup(%q) = %#v, want canonical %#v", aliasFlag, alias, primary)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +27,25 @@ func baseTableID(runtime *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(runtime.Str("table-id"))
|
||||
}
|
||||
|
||||
func pageSizeLimitAliasFlag() common.Flag {
|
||||
return common.Flag{Name: "page-size", Type: "int", Default: "0", Desc: "hidden alias for --limit", Hidden: true}
|
||||
}
|
||||
|
||||
func getPaginationLimit(runtime *common.RuntimeContext) int {
|
||||
if !runtime.Changed("limit") && runtime.Changed("page-size") {
|
||||
return runtime.Int("page-size")
|
||||
}
|
||||
return runtime.Int("limit")
|
||||
}
|
||||
|
||||
func validateLimitPageSizeAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("limit") && runtime.Changed("page-size") {
|
||||
return common.ValidationErrorf("--limit and --page-size are mutually exclusive; use --limit").
|
||||
WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -433,7 +433,7 @@ func TestBasePaginationHelpShowsDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseLimitDeclaresPageSizeAlias(t *testing.T) {
|
||||
func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
@@ -448,26 +448,18 @@ func TestBaseLimitDeclaresPageSizeAlias(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var declared *common.Flag
|
||||
for i := range tt.shortcut.Flags {
|
||||
if tt.shortcut.Flags[i].Name == "limit" {
|
||||
declared = &tt.shortcut.Flags[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if declared == nil || len(declared.Aliases) != 1 || declared.Aliases[0] != "page-size" {
|
||||
t.Fatalf("--limit aliases = %#v, want [page-size]", declared)
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
flag := cmd.Flags().Lookup("page-size")
|
||||
if flag == nil || flag.Name != "limit" {
|
||||
t.Fatalf("Lookup(page-size) = %#v, want canonical --limit", flag)
|
||||
if flag == nil {
|
||||
t.Fatal("flag --page-size missing")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("flag --page-size must be hidden")
|
||||
}
|
||||
if strings.Contains(cmd.Flags().FlagUsages(), "--page-size") {
|
||||
t.Fatalf("help should not list alias --page-size:\n%s", cmd.Flags().FlagUsages())
|
||||
t.Fatalf("help should not include hidden --page-size:\n%s", cmd.Flags().FlagUsages())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1462,6 +1454,47 @@ func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
),
|
||||
param: "--limit",
|
||||
},
|
||||
{
|
||||
name: "table list page-size alias",
|
||||
shortcut: BaseTableList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"page-size": 101}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "field list page-size alias",
|
||||
shortcut: BaseFieldList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 201}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "field search options page-size alias",
|
||||
shortcut: BaseFieldSearchOptions,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "field-id": "fld_1"}, nil, map[string]int{"page-size": 201}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "view list page-size alias",
|
||||
shortcut: BaseViewList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 201}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "record list page-size alias",
|
||||
shortcut: BaseRecordList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 0}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "record search page-size alias",
|
||||
shortcut: BaseRecordSearch,
|
||||
runtime: newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
map[string]int{"page-size": 201},
|
||||
),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "form list",
|
||||
shortcut: BaseFormsList,
|
||||
@@ -1503,6 +1536,53 @@ func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseLimitPageSizeAliasRejectsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
runtime *common.RuntimeContext
|
||||
}{
|
||||
{
|
||||
name: "table list",
|
||||
shortcut: BaseTableList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"limit": 50, "page-size": 50}),
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
runtime: newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
map[string]int{"limit": 10, "page-size": 10},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.shortcut.Validate == nil {
|
||||
t.Fatalf("%s missing Validate", tt.shortcut.Command)
|
||||
}
|
||||
err := tt.shortcut.Validate(ctx, tt.runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--page-size" {
|
||||
t.Fatalf("param=%q, want --page-size", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Message, "mutually exclusive") {
|
||||
t.Fatalf("message=%q, want mutually exclusive", validationErr.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
|
||||
@@ -20,11 +20,22 @@ var BaseFieldList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200)
|
||||
return err
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: dryRunFieldList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -19,7 +19,7 @@ func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
|
||||
Params(map[string]interface{}{"offset": offset, "limit": limit}).
|
||||
@@ -73,7 +73,7 @@ func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *commo
|
||||
}
|
||||
|
||||
func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{
|
||||
"offset": runtime.Int("offset"),
|
||||
"limit": limit,
|
||||
@@ -132,7 +132,7 @@ func executeFieldList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -315,7 +315,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
fieldRef := runtime.Str("field-id")
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{
|
||||
"offset": runtime.Int("offset"),
|
||||
"limit": limit,
|
||||
|
||||
@@ -22,15 +22,26 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
fieldRefFlag(true),
|
||||
{Name: "keyword", Desc: "keyword for option query"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "30", Desc: "pagination size, range 1-200"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for select fields, whether multiple is false or true.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 30, 1, 200)
|
||||
return err
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 30, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 30, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: dryRunFieldSearchOptions,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,8 @@ var BaseRecordList = common.Shortcut{
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
@@ -47,9 +48,17 @@ var BaseRecordList = common.Shortcut{
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
@@ -522,7 +522,7 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
|
||||
@@ -190,7 +190,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
offset = 0
|
||||
}
|
||||
body["offset"] = offset
|
||||
body["limit"] = runtime.Int("limit")
|
||||
body["limit"] = getPaginationLimit(runtime)
|
||||
return body, applyRecordQueryToBody(runtime, body)
|
||||
}
|
||||
|
||||
@@ -260,9 +260,17 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
return baseFlagErrorf("--search-field is required unless --json is used")
|
||||
}
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 10, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 10, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,6 +287,7 @@ func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []strin
|
||||
"view-id",
|
||||
"offset",
|
||||
"limit",
|
||||
"page-size",
|
||||
}
|
||||
params := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
|
||||
@@ -30,7 +30,8 @@ var BaseRecordSearch = common.Shortcut{
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
|
||||
{Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
|
||||
@@ -19,11 +19,22 @@ var BaseTableList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 50, 1, 100)
|
||||
return err
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 50, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 50, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: dryRunTableList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -15,7 +15,7 @@ func dryRunTableList(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables").
|
||||
Params(map[string]interface{}{"offset": offset, "limit": limit}).
|
||||
@@ -62,7 +62,7 @@ func executeTableList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,11 +20,22 @@ var BaseViewList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200)
|
||||
return err
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: dryRunViewList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -23,7 +23,7 @@ func dryRunViewList(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
return dryRunViewBase(runtime).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/views").
|
||||
Params(map[string]interface{}{"offset": offset, "limit": limit})
|
||||
@@ -154,7 +154,7 @@ func executeViewList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := runtime.Int("limit")
|
||||
limit := getPaginationLimit(runtime)
|
||||
views, total, err := listAllViews(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// installFlagAliases makes declarative Flag.Aliases parse-time synonyms for
|
||||
// their canonical flag. Only the canonical pflag is registered, so aliases
|
||||
// automatically share its type, default, enum, required, input, help, and
|
||||
// schema contracts. Downstream code therefore reads only the canonical name.
|
||||
//
|
||||
// Aliases use the canonical flag type's normal repeated-flag semantics. For
|
||||
// scalar flags, the last canonical/alias occurrence wins; collection flags
|
||||
// retain pflag's accumulation behavior. Value-transforming compatibility
|
||||
// inputs are not aliases and use the framework Normalize phase instead.
|
||||
func installFlagAliases(cmd *cobra.Command, flags []Flag) {
|
||||
specs := make([]flagalias.Spec, 0)
|
||||
for _, flag := range flags {
|
||||
if len(flag.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
specs = append(specs, flagalias.Spec{Canonical: flag.Name, Aliases: flag.Aliases})
|
||||
}
|
||||
flagalias.MustBind(cmd, specs)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// FlagNormalizer lets a business domain canonicalize compatibility inputs whose
|
||||
// value grammar or semantics differ from the canonical flag. Exact name
|
||||
// synonyms belong in Flag.Aliases and must not use this hook.
|
||||
type FlagNormalizer func(context.Context, *FlagContext) error
|
||||
|
||||
// ChainNormalizers composes independent business adapters into one ordered
|
||||
// Shortcut.Normalize hook. Nil stages are ignored and the first error stops
|
||||
// the chain.
|
||||
func ChainNormalizers(normalizers ...FlagNormalizer) FlagNormalizer {
|
||||
active := make([]FlagNormalizer, 0, len(normalizers))
|
||||
for _, normalize := range normalizers {
|
||||
if normalize != nil {
|
||||
active = append(active, normalize)
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, flags *FlagContext) error {
|
||||
for _, normalize := range active {
|
||||
if err := normalize(ctx, flags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// FlagContext is the deliberately narrow context exposed to Shortcut.Normalize.
|
||||
// Normalize runs after pflag/Cobra structural validation and @file/stdin
|
||||
// resolution, but before canonical flag validation. It may inspect accepted
|
||||
// inputs and populate canonical flags; it does not expose identity, config, API
|
||||
// clients, or execution logic.
|
||||
type FlagContext struct {
|
||||
runtime *RuntimeContext
|
||||
}
|
||||
|
||||
// FlagContext returns the business-normalization view of runtime. It is
|
||||
// primarily useful to tests and adapters that invoke a shortcut normalizer
|
||||
// directly; normal command execution constructs the same view automatically.
|
||||
func (ctx *RuntimeContext) FlagContext() *FlagContext {
|
||||
return &FlagContext{runtime: ctx}
|
||||
}
|
||||
|
||||
// Str returns a string flag value.
|
||||
func (ctx *FlagContext) Str(name string) string { return ctx.runtime.Str(name) }
|
||||
|
||||
// Bool returns a bool flag value.
|
||||
func (ctx *FlagContext) Bool(name string) bool { return ctx.runtime.Bool(name) }
|
||||
|
||||
// Int returns an int flag value.
|
||||
func (ctx *FlagContext) Int(name string) int { return ctx.runtime.Int(name) }
|
||||
|
||||
// Float64 returns a float64 flag value.
|
||||
func (ctx *FlagContext) Float64(name string) float64 { return ctx.runtime.Float64(name) }
|
||||
|
||||
// IntArray returns an int-slice flag value.
|
||||
func (ctx *FlagContext) IntArray(name string) []int { return ctx.runtime.IntArray(name) }
|
||||
|
||||
// StrArray returns a repeated string-array flag value.
|
||||
func (ctx *FlagContext) StrArray(name string) []string { return ctx.runtime.StrArray(name) }
|
||||
|
||||
// StrSlice returns a CSV-aware string-slice flag value.
|
||||
func (ctx *FlagContext) StrSlice(name string) []string { return ctx.runtime.StrSlice(name) }
|
||||
|
||||
// Changed reports whether a spelling has populated this flag in the effective
|
||||
// parse state. Before SetCanonical is called, it distinguishes direct canonical
|
||||
// input from a legacy compatibility flag. SetCanonical then marks the canonical
|
||||
// flag changed so every downstream execution phase sees one state.
|
||||
func (ctx *FlagContext) Changed(name string) bool { return ctx.runtime.Changed(name) }
|
||||
|
||||
// SetCanonical writes a normalized value to a registered canonical flag. It
|
||||
// uses FlagSet.Set rather than Value.Set intentionally so the canonical flag is
|
||||
// marked changed and becomes the single effective input observed by Validate,
|
||||
// DryRun, and Execute.
|
||||
func (ctx *FlagContext) SetCanonical(name, value string) error {
|
||||
return ctx.SetCanonicalFrom("", name, value)
|
||||
}
|
||||
|
||||
// SetCanonicalFrom is SetCanonical with the source spelling used for immediate
|
||||
// conversion-error attribution. The source is not persisted: downstream
|
||||
// business validation can inspect the original compatibility flag's Changed
|
||||
// state when it needs to name the caller's input.
|
||||
func (ctx *FlagContext) SetCanonicalFrom(source, name, value string) error {
|
||||
if ctx == nil || ctx.runtime == nil || ctx.runtime.Cmd == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "cannot set canonical flag --%s: flag context is not initialized", name)
|
||||
}
|
||||
if ctx.runtime.Cmd.Flags().Lookup(name) == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "cannot set canonical flag --%s: flag is not registered", name)
|
||||
}
|
||||
if err := ctx.runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
param := name
|
||||
if source != "" {
|
||||
param = source
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot normalize --%s into --%s: %v", param, name, err).
|
||||
WithParam("--" + param).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileIO returns the command's file provider for compatibility inputs that
|
||||
// need to interpret legacy file syntax after the framework resolves Flag.Input.
|
||||
func (ctx *FlagContext) FileIO() fileio.FileIO {
|
||||
if ctx == nil || ctx.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.runtime.FileIO()
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
PageAllFlagName = "page-all"
|
||||
|
||||
pageLimitFlagName = "page-limit"
|
||||
pageLimitDefault = 10
|
||||
pageLimitMaximum = 1000
|
||||
|
||||
pageDelayFlagName = "page-delay"
|
||||
pageDelayDefault = 200
|
||||
pageDelayMaximum = 60_000
|
||||
)
|
||||
|
||||
// PageAllFlags returns the shared pagination control definitions.
|
||||
// Each call returns a fresh slice so shortcuts cannot mutate each other.
|
||||
func PageAllFlags() []Flag {
|
||||
return []Flag{
|
||||
{
|
||||
Name: PageAllFlagName,
|
||||
Type: "bool",
|
||||
Desc: "automatically paginate until exhaustion or --page-limit",
|
||||
},
|
||||
{
|
||||
Name: pageLimitFlagName,
|
||||
Type: "int",
|
||||
Default: fmt.Sprintf("%d", pageLimitDefault),
|
||||
Desc: fmt.Sprintf("maximum pages fetched by --page-all (%d-%d)", 1, pageLimitMaximum),
|
||||
},
|
||||
{
|
||||
Name: pageDelayFlagName,
|
||||
Type: "int",
|
||||
Default: fmt.Sprintf("%d", pageDelayDefault),
|
||||
Desc: fmt.Sprintf("delay in milliseconds between pages with --page-all (%d-%d; 0 disables throttling)",
|
||||
0, pageDelayMaximum),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePageAllFlags validates the shared page budget and inter-page delay.
|
||||
// PaginateInto repeats this check defensively for callers that invoke Execute
|
||||
// directly in tests.
|
||||
func ValidatePageAllFlags(runtime *RuntimeContext) error {
|
||||
_, err := pageAllValues(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
type pageAllConfig struct {
|
||||
enabled bool
|
||||
maxPages int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func pageAllValues(runtime *RuntimeContext) (pageAllConfig, error) {
|
||||
if runtime == nil || runtime.Cmd == nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination requires a mounted shortcut command")
|
||||
}
|
||||
flags := runtime.Cmd.Flags()
|
||||
if flags.Lookup(PageAllFlagName) == nil || flags.Lookup(pageLimitFlagName) == nil || flags.Lookup(pageDelayFlagName) == nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination flags are not registered; append common.PageAllFlags() to the shortcut flags")
|
||||
}
|
||||
enabled, err := flags.GetBool(PageAllFlagName)
|
||||
if err != nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"read pagination flag --%s: %v", PageAllFlagName, err).WithCause(err)
|
||||
}
|
||||
limit, err := flags.GetInt(pageLimitFlagName)
|
||||
if err != nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"read pagination flag --%s: %v", pageLimitFlagName, err).WithCause(err)
|
||||
}
|
||||
if limit < 1 || limit > pageLimitMaximum {
|
||||
return pageAllConfig{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--%s must be an integer between 1 and %d", pageLimitFlagName, pageLimitMaximum).
|
||||
WithParam("--" + pageLimitFlagName)
|
||||
}
|
||||
delayMillis, err := flags.GetInt(pageDelayFlagName)
|
||||
if err != nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"read pagination flag --%s: %v", pageDelayFlagName, err).WithCause(err)
|
||||
}
|
||||
if delayMillis < 0 || delayMillis > pageDelayMaximum {
|
||||
return pageAllConfig{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--%s must be an integer between 0 and %d", pageDelayFlagName, pageDelayMaximum).
|
||||
WithParam("--" + pageDelayFlagName)
|
||||
}
|
||||
return pageAllConfig{
|
||||
enabled: enabled,
|
||||
maxPages: limit,
|
||||
delay: time.Duration(delayMillis) * time.Millisecond,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PageRequest describes one paginated API walk. Pagination controls are not
|
||||
// repeated here: PaginateInto derives the policy from the command's standard
|
||||
// --page-all and --page-limit flags.
|
||||
type PageRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Params map[string]interface{}
|
||||
Body interface{}
|
||||
}
|
||||
|
||||
// PageAccumulator owns the business-specific meaning of combining pages.
|
||||
// Framework pagination deliberately knows nothing about item field names or
|
||||
// whether non-item fields come from the first, last, or every page.
|
||||
type PageAccumulator[T any] interface {
|
||||
AddPage(T) error
|
||||
}
|
||||
|
||||
// PaginateInto walks an endpoint and decodes each successful data object into
|
||||
// T before handing it to dst. A normal invocation and --page-all use the same
|
||||
// path: the former has a one-page policy, while the latter uses --page-limit.
|
||||
// An explicit --page-token is only the starting cursor and never changes that
|
||||
// policy. Multi-page runs wait --page-delay between successful page requests;
|
||||
// the wait is context-aware and never occurs before page 1 or after the final
|
||||
// page.
|
||||
//
|
||||
// The returned metadata describes the fetch stage. Callers that apply global
|
||||
// filters or enrichment should set Items to the final emitted record count.
|
||||
// Keeping the typed-page boundary here also keeps shortcut call sites stable
|
||||
// when the transport supplies a response-native decode method.
|
||||
func PaginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageAccumulator[T]) (*output.PaginationMeta, error) {
|
||||
return paginateInto(runtime, request, dst, waitPageDelay)
|
||||
}
|
||||
|
||||
type pageDelayWaiter func(context.Context, time.Duration) error
|
||||
|
||||
func paginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageAccumulator[T], wait pageDelayWaiter) (*output.PaginationMeta, error) {
|
||||
meta := &output.PaginationMeta{}
|
||||
policy, err := resolvePaginationPolicy(runtime)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
pageToken := pageTokenParam(request.Params)
|
||||
seen := make(map[string]struct{})
|
||||
if pageToken != "" {
|
||||
seen[pageToken] = struct{}{}
|
||||
}
|
||||
|
||||
// maxPages is always in [1, pageLimitMaximum]. Keeping the bound in the
|
||||
// loop statement makes finite execution a structural invariant, independent
|
||||
// of cursor quality and of any future exit-condition changes below.
|
||||
for pageNumber := 1; pageNumber <= policy.maxPages; pageNumber++ {
|
||||
params := clonePageParams(request.Params)
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
if policy.showProgress {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body)
|
||||
if err != nil {
|
||||
meta.NextToken = pageToken
|
||||
return meta, err
|
||||
}
|
||||
page, err := decodePageData[T](data, pageNumber)
|
||||
if err != nil {
|
||||
meta.NextToken = pageToken
|
||||
return meta, err
|
||||
}
|
||||
if err := dst.AddPage(page); err != nil {
|
||||
meta.NextToken = pageToken
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return meta, err
|
||||
}
|
||||
return meta, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"accumulate pagination page %d: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
meta.Pages++
|
||||
|
||||
hasMore, nextPageToken := PaginationMeta(data)
|
||||
if !hasMore {
|
||||
meta.Complete = true
|
||||
meta.NextToken = ""
|
||||
return meta, nil
|
||||
}
|
||||
if nextPageToken == "" {
|
||||
return meta, invalidPageCursor("response reports more pages but returned no page token")
|
||||
}
|
||||
if _, repeated := seen[nextPageToken]; repeated {
|
||||
return meta, invalidPageCursor("response repeated page token %q, which would paginate forever", nextPageToken)
|
||||
}
|
||||
|
||||
meta.NextToken = nextPageToken
|
||||
if pageNumber == policy.maxPages {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
seen[nextPageToken] = struct{}{}
|
||||
pageToken = nextPageToken
|
||||
if policy.pageDelay > 0 {
|
||||
ctx := runtime.Ctx()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := wait(ctx, policy.pageDelay); err != nil {
|
||||
return meta, paginationWaitError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return meta, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination exhausted its page budget without producing a terminal result")
|
||||
}
|
||||
|
||||
type paginationPolicy struct {
|
||||
maxPages int
|
||||
pageDelay time.Duration
|
||||
showProgress bool
|
||||
}
|
||||
|
||||
// resolvePaginationPolicy resolves the framework's standard list semantics.
|
||||
// Even a one-page call is a pagination run; --page-all only changes its page
|
||||
// budget and progress presentation.
|
||||
func resolvePaginationPolicy(runtime *RuntimeContext) (paginationPolicy, error) {
|
||||
config, err := pageAllValues(runtime)
|
||||
if err != nil {
|
||||
return paginationPolicy{}, err
|
||||
}
|
||||
if !config.enabled {
|
||||
return paginationPolicy{maxPages: 1}, nil
|
||||
}
|
||||
return paginationPolicy{
|
||||
maxPages: config.maxPages,
|
||||
pageDelay: config.delay,
|
||||
showProgress: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func waitPageDelay(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func paginationWaitError(err error) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype,
|
||||
"pagination interrupted while waiting between pages: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// decodePageData isolates the current map-returning RuntimeContext boundary.
|
||||
// A response-native decoder can replace this adapter without changing either
|
||||
// PaginateInto's public contract or any shortcut accumulator.
|
||||
func decodePageData[T any](data map[string]interface{}, pageNumber int) (T, error) {
|
||||
var page T
|
||||
if data == nil {
|
||||
return page, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"pagination page %d response has no data object", pageNumber)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return page, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"encode pagination page %d for typed decoding: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&page); err != nil {
|
||||
return page, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"decode pagination page %d: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func clonePageParams(params map[string]interface{}) map[string]interface{} {
|
||||
cloned := make(map[string]interface{}, len(params)+1)
|
||||
for name, value := range params {
|
||||
cloned[name] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func pageTokenParam(params map[string]interface{}) string {
|
||||
switch value := params["page_token"].(type) {
|
||||
case string:
|
||||
return value
|
||||
case []string:
|
||||
if len(value) > 0 {
|
||||
return value[0]
|
||||
}
|
||||
case []interface{}:
|
||||
if len(value) > 0 {
|
||||
pageToken, _ := value[0].(string)
|
||||
return pageToken
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func invalidPageCursor(format string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
|
||||
WithHint("re-run without --page-all, or report the endpoint: its pagination cursor is inconsistent")
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type paginateIntoTestPage struct {
|
||||
Items []string `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
type paginateIntoTestResult struct {
|
||||
items []string
|
||||
hasMore bool
|
||||
pageToken string
|
||||
pages int
|
||||
}
|
||||
|
||||
func (result *paginateIntoTestResult) AddPage(page paginateIntoTestPage) error {
|
||||
result.items = append(result.items, page.Items...)
|
||||
result.hasMore = page.HasMore
|
||||
result.pageToken = page.PageToken
|
||||
result.pages++
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPaginateIntoTestRuntime(t *testing.T, flags map[string]string) (*RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
|
||||
factory, _, stderr, registry := cmdutil.TestFactory(t, config)
|
||||
cmd := &cobra.Command{Use: "+list"}
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", pageDelayDefault, "")
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
runtime := TestNewRuntimeContextForAPI(context.Background(), cmd, config, factory, core.AsUser)
|
||||
return runtime, stderr, registry
|
||||
}
|
||||
|
||||
func TestPageAllFlagsContract(t *testing.T) {
|
||||
flags := PageAllFlags()
|
||||
if len(flags) != 3 {
|
||||
t.Fatalf("PageAllFlags() returned %d flags, want 3", len(flags))
|
||||
}
|
||||
if got := flags[0]; got.Name != PageAllFlagName || got.Type != "bool" || got.Default != "" {
|
||||
t.Fatalf("page-all flag = %#v", got)
|
||||
}
|
||||
if got := flags[1]; got.Name != pageLimitFlagName || got.Type != "int" || got.Default != strconv.Itoa(pageLimitDefault) || !strings.Contains(got.Desc, strconv.Itoa(pageLimitMaximum)) {
|
||||
t.Fatalf("page-limit flag = %#v", got)
|
||||
}
|
||||
if got := flags[2]; got.Name != pageDelayFlagName || got.Type != "int" || got.Default != strconv.Itoa(pageDelayDefault) || !strings.Contains(got.Desc, strconv.Itoa(pageDelayMaximum)) {
|
||||
t.Fatalf("page-delay flag = %#v", got)
|
||||
}
|
||||
|
||||
flags[0].Desc = "mutated"
|
||||
if PageAllFlags()[0].Desc == "mutated" {
|
||||
t.Fatal("PageAllFlags() reused mutable definitions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoDecodesAndAccumulatesPages(t *testing.T) {
|
||||
runtime, stderr, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true", "page-delay": "0"})
|
||||
var requestTokens []string
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"first"}, "has_more": true, "page_token": "next"},
|
||||
{"items": []string{"second"}, "has_more": false, "page_token": "final"},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
OnMatch: func(request *http.Request) {
|
||||
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
params := map[string]interface{}{"page_size": 20}
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
Params: params,
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(result.items, []string{"first", "second"}) {
|
||||
t.Fatalf("items = %v, want [first second]", result.items)
|
||||
}
|
||||
if result.pages != 2 || result.hasMore || result.pageToken != "final" {
|
||||
t.Fatalf("result meta = pages:%d has_more:%v page_token:%q", result.pages, result.hasMore, result.pageToken)
|
||||
}
|
||||
if !meta.Complete || meta.Pages != 2 || meta.NextToken != "" {
|
||||
t.Fatalf("pagination meta = %+v, want complete two-page run", meta)
|
||||
}
|
||||
if !reflect.DeepEqual(requestTokens, []string{"", "next"}) {
|
||||
t.Fatalf("request page tokens = %v, want [\"\" \"next\"]", requestTokens)
|
||||
}
|
||||
if _, mutated := params["page_token"]; mutated {
|
||||
t.Fatalf("PaginateInto mutated caller params: %#v", params)
|
||||
}
|
||||
for _, want := range []string{"[page 1] fetching...", "[page 2] fetching..."} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoWaitsOnlyBetweenPages(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"first"}, "has_more": true, "page_token": "next"},
|
||||
{"items": []string{"second"}, "has_more": false},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
})
|
||||
}
|
||||
|
||||
var waits []time.Duration
|
||||
meta, err := paginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{}, func(_ context.Context, delay time.Duration) error {
|
||||
waits = append(waits, delay)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("paginateInto() error = %v", err)
|
||||
}
|
||||
if !meta.Complete || meta.Pages != 2 {
|
||||
t.Fatalf("pagination meta = %+v, want complete two-page run", meta)
|
||||
}
|
||||
if !reflect.DeepEqual(waits, []time.Duration{pageDelayDefault * time.Millisecond}) {
|
||||
t.Fatalf("page waits = %v, want one %s wait", waits, pageDelayDefault*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoDelayCancellationIsTypedAndResumable(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []string{"first"},
|
||||
"has_more": true,
|
||||
"page_token": "resume",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
meta, err := paginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{}, func(_ context.Context, _ time.Duration) error {
|
||||
return context.Canceled
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("paginateInto() error = %v, want context.Canceled cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("pagination cancellation problem = %#v, %v; want network/transport", problem, ok)
|
||||
}
|
||||
if meta.Pages != 1 || meta.Complete || meta.NextToken != "resume" {
|
||||
t.Fatalf("pagination meta = %+v, want resumable first page", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitPageDelayHonorsCanceledContextWithoutSleeping(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitPageDelay(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("waitPageDelay() error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoUsesOnePagePolicyByDefault(t *testing.T) {
|
||||
runtime, stderr, registry := newPaginateIntoTestRuntime(t, nil)
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []string{"first"},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if result.pages != 1 || !reflect.DeepEqual(result.items, []string{"first"}) {
|
||||
t.Fatalf("result = %+v, want one accumulated page", result)
|
||||
}
|
||||
if meta.Complete || meta.Pages != 1 || meta.NextToken != "next" {
|
||||
t.Fatalf("pagination meta = %+v, want incomplete one-page run", meta)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("default one-page run wrote progress to stderr: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoStopsAtConfiguredPageLimit(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{
|
||||
"page-all": "true",
|
||||
"page-limit": "2",
|
||||
"page-delay": "0",
|
||||
})
|
||||
var calls int
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"first"}, "has_more": true, "page_token": "second"},
|
||||
{"items": []string{"second"}, "has_more": true, "page_token": "resume"},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
OnMatch: func(_ *http.Request) {
|
||||
calls++
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if calls != 2 || result.pages != 2 {
|
||||
t.Fatalf("page calls = %d, accumulated pages = %d; want hard stop at 2", calls, result.pages)
|
||||
}
|
||||
if meta.Complete || meta.Pages != 2 || meta.NextToken != "resume" {
|
||||
t.Fatalf("pagination meta = %+v, want incomplete result resumable at %q", meta, "resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoContinuesFromExplicitCursorWithPageAll(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true", "page-delay": "0"})
|
||||
var requestTokens []string
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"from-resume"}, "has_more": true, "page_token": "next"},
|
||||
{"items": []string{"after-resume"}, "has_more": false},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
OnMatch: func(request *http.Request) {
|
||||
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
// SDK request builders represent query values as []string. Pin that
|
||||
// representation here so an explicit resume cursor remains compatible
|
||||
// with both SDK-built and raw map requests.
|
||||
Params: map[string]interface{}{"page_token": []string{"resume"}},
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(requestTokens, []string{"resume", "next"}) {
|
||||
t.Fatalf("request page tokens = %v, want [resume next]", requestTokens)
|
||||
}
|
||||
if !reflect.DeepEqual(result.items, []string{"from-resume", "after-resume"}) || !meta.Complete || meta.Pages != 2 {
|
||||
t.Fatalf("result = %+v meta = %+v", result, meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsStartingCursorRepeatedByServer(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, nil)
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []string{"item"},
|
||||
"has_more": true,
|
||||
"page_token": "resume",
|
||||
},
|
||||
},
|
||||
OnMatch: func(request *http.Request) {
|
||||
if token := request.URL.Query().Get("page_token"); token != "resume" {
|
||||
t.Errorf("request page_token = %q, want resume", token)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
Params: map[string]interface{}{"page_token": "resume"},
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("PaginateInto() error = %v, want typed error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = (%q, %q), want (%q, %q)",
|
||||
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "repeated page token") {
|
||||
t.Fatalf("problem message = %q, want repeated-token diagnosis", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsPageOutsideTypedContract(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, nil)
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"unexpected": true}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("PaginateInto() error = %v, want typed error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = (%q, %q), want (%q, %q)",
|
||||
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "decode pagination page 1") {
|
||||
t.Fatalf("problem message = %q, want page-specific decode diagnosis", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsPageLimitOutsideSharedBounds(t *testing.T) {
|
||||
for _, limit := range []string{"-1", "0", strconv.Itoa(pageLimitMaximum + 1)} {
|
||||
t.Run(limit, func(t *testing.T) {
|
||||
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{
|
||||
PageAllFlagName: "true",
|
||||
pageLimitFlagName: limit,
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--page-limit" {
|
||||
t.Fatalf("PaginateInto() problem = %#v, %v; want invalid --page-limit", problem, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsPageDelayOutsideSharedBounds(t *testing.T) {
|
||||
for _, delay := range []string{"-1", strconv.Itoa(pageDelayMaximum + 1)} {
|
||||
t.Run(delay, func(t *testing.T) {
|
||||
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{
|
||||
pageDelayFlagName: delay,
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--page-delay" {
|
||||
t.Fatalf("PaginateInto() problem = %#v, %v; want invalid --page-delay", problem, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -242,10 +242,8 @@ func (ctx *RuntimeContext) StrSlice(name string) []string {
|
||||
return v
|
||||
}
|
||||
|
||||
// Changed reports whether parsing or compatibility normalization populated the
|
||||
// named flag, as opposed to the flag carrying only its default value. During a
|
||||
// Normalize hook, check legacy and canonical spellings before SetCanonical to
|
||||
// distinguish which spelling the caller supplied.
|
||||
// Changed reports whether the user explicitly set the named flag on the
|
||||
// command line, as opposed to the flag carrying its default value.
|
||||
func (ctx *RuntimeContext) Changed(name string) bool {
|
||||
f := ctx.Cmd.Flags().Lookup(name)
|
||||
if f == nil {
|
||||
@@ -873,12 +871,10 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
if shortcut.PostMount != nil {
|
||||
shortcut.PostMount(cmd)
|
||||
}
|
||||
installFlagAliases(cmd, shortcut.Flags)
|
||||
}
|
||||
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → runtime →
|
||||
// canonical validation → execute.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
@@ -904,6 +900,7 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -924,24 +921,12 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Normalize != nil {
|
||||
// Normalize is opt-in and consumes resolved values. Shortcuts without a
|
||||
// normalizer retain the established enum-before-input execution order.
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
flagContext := rctx.FlagContext()
|
||||
if err := s.Normalize(rctx.ctx, flagContext); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Normalize == nil {
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestShortcutFlagAliasesResolveToCanonicalContract(t *testing.T) {
|
||||
shortcut := Shortcut{
|
||||
Service: "im", Command: "+alias-test", Description: "x",
|
||||
Flags: []Flag{
|
||||
{
|
||||
Name: "order",
|
||||
Aliases: []string{"sort", "sort-order"},
|
||||
Default: "desc",
|
||||
Enum: []string{"asc", "desc"},
|
||||
Required: true,
|
||||
Desc: "message order",
|
||||
},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if cmd.PreRunE != nil || cmd.PreRun != nil {
|
||||
t.Fatal("declarative aliases must not install or take over Cobra PreRun hooks")
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort-order", "asc"}); err != nil {
|
||||
t.Fatalf("ParseFlags(alias) error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("--sort-order resolved order = %q, want asc", got)
|
||||
}
|
||||
if !cmd.Flags().Changed("order") {
|
||||
t.Fatal("alias must mark the canonical flag changed")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err != nil {
|
||||
t.Fatalf("alias must satisfy canonical Required contract: %v", err)
|
||||
}
|
||||
if err := validateEnumFlags(&RuntimeContext{Cmd: cmd}, shortcut.Flags); err != nil {
|
||||
t.Fatalf("alias must share canonical Enum contract: %v", err)
|
||||
}
|
||||
|
||||
aliasLookup := cmd.Flags().Lookup("sort-order")
|
||||
if aliasLookup == nil || aliasLookup.Name != "order" {
|
||||
t.Fatalf("Lookup(alias) = %#v, want canonical --order flag", aliasLookup)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--sort") {
|
||||
t.Fatalf("aliases leaked into help:\n%s", usage)
|
||||
}
|
||||
var registeredAliases []string
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag.Name == "sort" || flag.Name == "sort-order" {
|
||||
registeredAliases = append(registeredAliases, flag.Name)
|
||||
}
|
||||
})
|
||||
if len(registeredAliases) != 0 {
|
||||
t.Fatalf("aliases were registered as independent flags: %v", registeredAliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesUseRepeatedFlagLastWinsSemantics(t *testing.T) {
|
||||
shortcut := Shortcut{
|
||||
Service: "im", Command: "+alias-order", Description: "x",
|
||||
Flags: []Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, Default: "desc",
|
||||
}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "alias last", args: []string{"--order", "asc", "--sort-order", "desc"}, want: "desc"},
|
||||
{name: "canonical last", args: []string{"--sort-order", "desc", "--order", "asc"}, want: "asc"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if err := cmd.ParseFlags(test.args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", test.args, err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != test.want {
|
||||
t.Fatalf("order = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesComposeWithPostMountNormalizer(t *testing.T) {
|
||||
shortcut := Shortcut{
|
||||
Service: "im", Command: "+alias-compose", Description: "x",
|
||||
Flags: []Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, Default: "desc",
|
||||
}},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if err := cmd.ParseFlags([]string{"--sort_order", "asc"}); err != nil {
|
||||
t.Fatalf("composed alias parse error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q, want asc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesRejectCollisionsAtMount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []Flag
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "canonical collision",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"query"}},
|
||||
{Name: "query"},
|
||||
},
|
||||
want: "conflicts with registered flag",
|
||||
},
|
||||
{
|
||||
name: "framework flag collision",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"format"}}},
|
||||
want: "conflicts with registered flag",
|
||||
},
|
||||
{
|
||||
name: "cobra help collision",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"help"}}},
|
||||
want: "conflicts with registered flag --help",
|
||||
},
|
||||
{
|
||||
name: "ambiguous alias",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort"}},
|
||||
{Name: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
want: "maps to both",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatal("Mount() did not reject alias collision")
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(recovered), test.want) {
|
||||
t.Fatalf("panic = %q, want %q", recovered, test.want)
|
||||
}
|
||||
}()
|
||||
mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+alias-collision", Description: "x",
|
||||
Flags: test.flags,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesRejectCollisionAfterPostMountNormalization(t *testing.T) {
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatal("Mount() did not reject normalized alias collision")
|
||||
}
|
||||
if got := fmt.Sprint(recovered); !strings.Contains(got, "conflicts with registered flag --sort-order after normalization") {
|
||||
t.Fatalf("panic = %q", got)
|
||||
}
|
||||
}()
|
||||
|
||||
mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+alias-normalized-collision", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort_order"}},
|
||||
{Name: "sort-order"},
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestRunShortcutNormalizesAfterInputAndBeforeCanonicalValidation(t *testing.T) {
|
||||
var phases []string
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Flags: []Flag{
|
||||
{Name: "canonical", Enum: []string{"normalized"}},
|
||||
{Name: "legacy", Input: []string{Stdin}},
|
||||
},
|
||||
Normalize: func(_ context.Context, flags *FlagContext) error {
|
||||
phases = append(phases, "normalize:"+flags.Str("legacy"))
|
||||
return flags.SetCanonical("canonical", "normalized")
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *RuntimeContext) error {
|
||||
phases = append(phases, "validate:"+runtime.Str("canonical"))
|
||||
return nil
|
||||
},
|
||||
Execute: func(_ context.Context, runtime *RuntimeContext) error {
|
||||
phases = append(phases, "execute:"+runtime.Str("canonical"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
factory := newTestFactory()
|
||||
factory.IOStreams.In = strings.NewReader("resolved-input")
|
||||
cmd := newTestShortcutCmd(s, factory)
|
||||
if err := cmd.Flags().Set("legacy", "-"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runShortcut(cmd, factory, s, true); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
want := "normalize:resolved-input,validate:normalized,execute:normalized"
|
||||
if got := strings.Join(phases, ","); got != want {
|
||||
t.Fatalf("phases = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcutNormalizeFailureStopsCanonicalConsumers(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Normalize: func(context.Context, *FlagContext) error {
|
||||
return ValidationErrorf("legacy compatibility failed").WithParam("--legacy")
|
||||
},
|
||||
Validate: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Validate ran after Normalize failed")
|
||||
return nil
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute ran after Normalize failed")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
factory := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, factory)
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runShortcut(cmd, factory, s, true); err == nil {
|
||||
t.Fatal("runShortcut() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCanonicalFromClassifiesPFlagConversionFailure(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Flags: []Flag{
|
||||
{Name: "canonical", Type: "int"},
|
||||
{Name: "legacy"},
|
||||
},
|
||||
Normalize: func(_ context.Context, flags *FlagContext) error {
|
||||
return flags.SetCanonicalFrom("legacy", "canonical", flags.Str("legacy"))
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
factory := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, factory)
|
||||
if err := cmd.Flags().Set("legacy", "not-an-int"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := runShortcut(cmd, factory, s, true)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if validationErr.Param != "--legacy" {
|
||||
t.Fatalf("param = %q, want --legacy", validationErr.Param)
|
||||
}
|
||||
if errors.Unwrap(validationErr) == nil {
|
||||
t.Fatal("pflag conversion cause was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedShortcutNormalizeDoesNotExpandCobraPreRun(t *testing.T) {
|
||||
normalizeCalled := false
|
||||
shortcut := Shortcut{
|
||||
Service: "test", Command: "+normalize-required", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "canonical", Required: true},
|
||||
{Name: "legacy", Hidden: true},
|
||||
},
|
||||
Normalize: func(_ context.Context, flags *FlagContext) error {
|
||||
normalizeCalled = true
|
||||
if !flags.Changed("legacy") || flags.Changed("canonical") {
|
||||
return nil
|
||||
}
|
||||
return flags.SetCanonicalFrom("legacy", "canonical", flags.Str("legacy"))
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if err := cmd.ParseFlags([]string{"--legacy", "accepted"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.PreRunE != nil || cmd.PreRun != nil {
|
||||
t.Fatal("Normalize must not install or take over Cobra PreRun hooks")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err == nil {
|
||||
t.Fatal("a business Normalize hook must not satisfy Cobra Required")
|
||||
}
|
||||
if normalizeCalled {
|
||||
t.Fatal("Normalize ran before Cobra Required validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainNormalizersPreservesDeclarationOrderAndStopsOnError(t *testing.T) {
|
||||
var phases []string
|
||||
stop := ValidationErrorf("stop")
|
||||
chain := ChainNormalizers(
|
||||
func(context.Context, *FlagContext) error {
|
||||
phases = append(phases, "first")
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
func(context.Context, *FlagContext) error {
|
||||
phases = append(phases, "second")
|
||||
return stop
|
||||
},
|
||||
func(context.Context, *FlagContext) error {
|
||||
t.Fatal("normalizer ran after an error")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err := chain(context.Background(), nil); err != stop {
|
||||
t.Fatalf("error = %v, want stop", err)
|
||||
}
|
||||
if got := strings.Join(phases, ","); got != "first,second" {
|
||||
t.Fatalf("phases = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,11 @@ const (
|
||||
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // canonical flag name (e.g. "calendar-id")
|
||||
Aliases []string // exact semantic synonyms accepted at parse time; hidden from human help, exported in machine metadata
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
Required bool
|
||||
Enum []string // allowed values (e.g. ["asc", "desc"]); empty means no constraint
|
||||
Input []string // extra input sources: File (@path), Stdin (-); empty = flag value only
|
||||
@@ -55,17 +54,9 @@ type Shortcut struct {
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
|
||||
// Business logic hooks.
|
||||
// Normalize is the business-owned compatibility stage inside shortcut
|
||||
// execution. It runs after Cobra's structural flag checks and framework input
|
||||
// resolution, but before canonical validation.
|
||||
// Use it only when an accepted legacy input has a different value grammar or
|
||||
// meaning. Exact name synonyms belong in Flag.Aliases. Normalize cannot be
|
||||
// used to satisfy a Cobra Required flag; alternatives such as "A or legacy B"
|
||||
// are a business constraint and must be validated as such.
|
||||
Normalize FlagNormalizer
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
|
||||
@@ -40,6 +40,12 @@ func (c docCoverHTTPStatusCause) Error() string {
|
||||
return http.StatusText(int(c))
|
||||
}
|
||||
|
||||
type docCoverURLGuardError string
|
||||
|
||||
func (e docCoverURLGuardError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var docCoverAllowedContentTypes = map[string]string{
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
@@ -536,7 +542,7 @@ func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, ra
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
baseClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -667,9 +673,6 @@ func isUnsafeDocCoverIP(ip net.IP) bool {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 0 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
@@ -698,15 +701,13 @@ func isUnsafeDocCoverIP(ip net.IP) bool {
|
||||
|
||||
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
|
||||
if base == nil {
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.ExternalHTTPClient.
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
|
||||
}
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
}
|
||||
cloned.Transport = validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{ //nolint:forbidigo // guarded external download
|
||||
MaxRedirects: 3,
|
||||
}).Transport
|
||||
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
|
||||
if len(via) >= 3 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
|
||||
@@ -722,3 +723,73 @@ func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
cloned.Proxy = nil
|
||||
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
if cloned.DialTLSContext != nil {
|
||||
origDialTLS := cloned.DialTLSContext
|
||||
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
if dialFn != nil {
|
||||
return dialFn(ctx, network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func validateDocCoverConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return docCoverURLGuardError("nil connection")
|
||||
}
|
||||
addr := conn.RemoteAddr()
|
||||
if addr == nil {
|
||||
return docCoverURLGuardError("missing remote address")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
host = addr.String()
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
return docCoverURLGuardError("invalid remote IP")
|
||||
}
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return docCoverURLGuardError("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -386,7 +386,6 @@ func TestValidateDocCoverURLHost(t *testing.T) {
|
||||
|
||||
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
for _, rawIP := range []string{
|
||||
"0.1.2.3",
|
||||
"10.0.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.1.1",
|
||||
@@ -407,26 +406,17 @@ func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientPreservesProxyPolicy(t *testing.T) {
|
||||
proxyErr := errors.New("proxy selected")
|
||||
directErr := errors.New("direct dialed")
|
||||
baseTransport := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, proxyErr
|
||||
},
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, directErr
|
||||
},
|
||||
}
|
||||
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
|
||||
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
baseClient := &http.Client{Transport: baseTransport}
|
||||
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://203.0.113.10/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if _, err := client.Transport.RoundTrip(req); !errors.Is(err, proxyErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want proxy policy error %v", err, proxyErr)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("cover URL downloader must not inherit proxy settings")
|
||||
}
|
||||
if baseTransport.Proxy == nil {
|
||||
t.Fatal("base transport proxy was mutated")
|
||||
@@ -456,6 +446,21 @@ func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
|
||||
if err := validateDocCoverConnRemoteIP(nil); err == nil {
|
||||
t.Fatal("expected nil connection error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
|
||||
t.Fatal("expected missing remote address error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
|
||||
t.Fatal("expected invalid remote IP error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
|
||||
t.Fatal("expected local remote IP error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverURLFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
@@ -657,6 +662,16 @@ func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
type testAddr string
|
||||
|
||||
func (a testAddr) Network() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
func (a testAddr) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
type repeatByteReader byte
|
||||
|
||||
func (r repeatByteReader) Read(p []byte) (int, error) {
|
||||
@@ -695,61 +710,3 @@ func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type opaqueDocCoverTransport struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *opaqueDocCoverTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.called = true
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
func TestNewDocCoverHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
|
||||
opaque := &opaqueDocCoverTransport{}
|
||||
client := newDocCoverHTTPClient(&http.Client{Transport: opaque})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
|
||||
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if opaque.called {
|
||||
t.Fatal("opaque transport was called after safe cloning failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDocCoverHTTPClientGuardsLegacyDialTLS(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
|
||||
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
|
||||
}}
|
||||
client := newDocCoverHTTPClient(&http.Client{Transport: base})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
|
||||
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ var DriveDownload = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
|
||||
return wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -23,30 +21,6 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
|
||||
// +download intact while giving callers a preview-based path to view content.
|
||||
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(problem.Hint, "drive +preview") {
|
||||
return err
|
||||
}
|
||||
hint := driveDownloadForbiddenPreviewHint()
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
problem.Hint = hint
|
||||
return err
|
||||
}
|
||||
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
|
||||
return err
|
||||
}
|
||||
|
||||
func driveDownloadForbiddenPreviewHint() string {
|
||||
const tokenArg = "<FILE_TOKEN>"
|
||||
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
|
||||
}
|
||||
|
||||
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
|
||||
// to a typed validation error:
|
||||
// - Path validation failures → "unsafe file path: ..."
|
||||
|
||||
@@ -1580,84 +1580,6 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_403/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("permission denied"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_403",
|
||||
"--output", "blocked.md",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 403 error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category=%q, want network", problem.Category)
|
||||
}
|
||||
if problem.Code != http.StatusForbidden {
|
||||
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "drive +preview") {
|
||||
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
|
||||
}
|
||||
if strings.Contains(problem.Hint, "file_403") {
|
||||
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
|
||||
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
|
||||
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_missing/download",
|
||||
Status: http.StatusNotFound,
|
||||
RawBody: []byte("not found"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_missing",
|
||||
"--output", "missing.md",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 404 error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Code != http.StatusNotFound {
|
||||
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
|
||||
}
|
||||
if strings.Contains(problem.Hint, "drive +preview") {
|
||||
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="////"`},
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
var DrivePreview = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+preview",
|
||||
Description: "View or download Drive file content, or list and fetch available preview artifacts",
|
||||
Description: "List or download available preview artifacts for a Drive file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded preview"},
|
||||
@@ -40,25 +40,6 @@ var DrivePreview = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedType := strings.TrimSpace(runtime.Str("type"))
|
||||
if requestedType == "source_file" {
|
||||
downloadParams := map[string]interface{}{
|
||||
"preview_type": drivePreviewTypeSourceFile,
|
||||
}
|
||||
if version != "" {
|
||||
downloadParams["version"] = version
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("Download the source file artifact").
|
||||
Params(downloadParams).
|
||||
Set("file_token", fileToken).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", requestedType).
|
||||
Set("selected_type", "source_file").
|
||||
Set("selected_type_code", drivePreviewTypeSourceFile).
|
||||
Set("output", runtime.Str("output"))
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
@@ -86,7 +67,7 @@ var DrivePreview = common.Shortcut{
|
||||
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
|
||||
Params(downloadParams).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", requestedType).
|
||||
Set("requested_type", runtime.Str("type")).
|
||||
Set("output", runtime.Str("output"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -101,25 +82,9 @@ var DrivePreview = common.Shortcut{
|
||||
body["version"] = version
|
||||
}
|
||||
|
||||
if requestedType == "source_file" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_type"] = "source_file"
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
|
||||
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
|
||||
if err != nil {
|
||||
if runtime.Bool("list-only") {
|
||||
return withDrivePreviewSourceFileHint(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
|
||||
@@ -27,8 +27,6 @@ const (
|
||||
drivePreviewIfExistsError = "error"
|
||||
drivePreviewIfExistsOverwrite = "overwrite"
|
||||
drivePreviewIfExistsRename = "rename"
|
||||
drivePreviewTypeSourceFile = "16"
|
||||
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
|
||||
)
|
||||
|
||||
type drivePreviewCandidate struct {
|
||||
@@ -90,9 +88,7 @@ var drivePreviewMimeToExt = map[string]string{
|
||||
"image/webp": ".webp",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/markdown": ".md",
|
||||
"text/plain": ".txt",
|
||||
"text/x-markdown": ".md",
|
||||
"text/xml": ".xml",
|
||||
"video/mp4": ".mp4",
|
||||
"application/octet-stream": "",
|
||||
@@ -468,7 +464,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -496,8 +492,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
|
||||
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
|
||||
// inference and the selected collision policy.
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
@@ -526,32 +522,6 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
|
||||
if drivePreviewOutputIsDirectory(runtime, outputPath) {
|
||||
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
|
||||
return filepath.Join(outputPath, fileName), resolution
|
||||
}
|
||||
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
}
|
||||
|
||||
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
|
||||
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
|
||||
if name == "" {
|
||||
name = driveDownloadNormalizeFileName(fallbackName)
|
||||
}
|
||||
name = sanitizeExportFileName(name, "preview")
|
||||
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
|
||||
return name, resolution
|
||||
}
|
||||
|
||||
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
|
||||
// target output path.
|
||||
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
|
||||
@@ -586,15 +556,6 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
|
||||
if filepath.Ext(outputPath) == "." {
|
||||
normalizedPath = strings.TrimSuffix(outputPath, ".")
|
||||
}
|
||||
if fallbackExt == "" {
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
return normalizedPath, nil
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
@@ -843,36 +804,6 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
|
||||
// API failures without changing their classification or server diagnostics.
|
||||
func withDrivePreviewSourceFileHint(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI {
|
||||
return err
|
||||
}
|
||||
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(problem.Hint, "--type source_file") {
|
||||
return err
|
||||
}
|
||||
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
problem.Hint = drivePreviewSourceFileHint
|
||||
return err
|
||||
}
|
||||
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
|
||||
return err
|
||||
}
|
||||
|
||||
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
|
||||
return problem != nil &&
|
||||
problem.Code == 1 &&
|
||||
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
|
||||
}
|
||||
|
||||
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
|
||||
// spec.
|
||||
func wrapDriveCoverUnavailable(requested string) error {
|
||||
|
||||
@@ -147,63 +147,6 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
|
||||
// source_file downloads the source file artifact without first fetching preview
|
||||
// candidates.
|
||||
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
Body: []byte("# markdown\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_source",
|
||||
"--type", "source_file",
|
||||
"--output", "artifacts/",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["requested_type"]; ok {
|
||||
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
|
||||
}
|
||||
if got := data["selected_type"]; got != "source_file" {
|
||||
t.Fatalf("selected_type=%v, want source_file", got)
|
||||
}
|
||||
if _, ok := data["selected_type_code"]; ok {
|
||||
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
gotBody, err := os.ReadFile(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
|
||||
}
|
||||
if string(gotBody) != "# markdown\n" {
|
||||
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
|
||||
// return an actionable validation error.
|
||||
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
|
||||
@@ -491,72 +434,6 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
|
||||
// dry-run documents the direct source artifact download path.
|
||||
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_source",
|
||||
"type": "source_file",
|
||||
"version": "7",
|
||||
"output": "source",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "download" {
|
||||
t.Fatalf("mode=%v, want download", got)
|
||||
}
|
||||
if got := data["requested_type"]; got != "source_file" {
|
||||
t.Fatalf("requested_type=%v, want source_file", got)
|
||||
}
|
||||
if got := data["selected_type"]; got != "source_file" {
|
||||
t.Fatalf("selected_type=%v, want source_file", got)
|
||||
}
|
||||
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
|
||||
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("len(api)=%d, want 1", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "GET" {
|
||||
t.Fatalf("method=%v, want GET", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
|
||||
t.Fatalf("url=%v, want preview_download", got)
|
||||
}
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
|
||||
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
|
||||
}
|
||||
if got := params["version"]; got != "7" {
|
||||
t.Fatalf("params.version=%v, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
|
||||
// explicit source_file request bypasses preview_result.
|
||||
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_source",
|
||||
"type": "source",
|
||||
"output": "source",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
|
||||
t.Fatalf("url=%v, want preview_result", got)
|
||||
}
|
||||
if _, ok := data["selected_type_code"]; ok {
|
||||
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
|
||||
// omits the request body when no version is supplied.
|
||||
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
|
||||
@@ -735,135 +612,6 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
|
||||
// failures keep server diagnostics while guiding callers to source_file.
|
||||
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1,
|
||||
"msg": "fail:mGetFilePreviewCore failed",
|
||||
"log_id": "log-preview-result",
|
||||
"error": map[string]interface{}{
|
||||
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server preview_result detail"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_markdown",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected preview_result error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category=%q, want api", problem.Category)
|
||||
}
|
||||
if problem.Code != 1 {
|
||||
t.Fatalf("code=%d, want 1", problem.Code)
|
||||
}
|
||||
if problem.LogID != "log-preview-result" {
|
||||
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
|
||||
}
|
||||
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
|
||||
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "server preview_result detail") {
|
||||
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
|
||||
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
|
||||
// errors are not reframed as source_file recovery.
|
||||
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
|
||||
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("retryable=false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
|
||||
// only rewrites eligible API errors and preserves existing source_file hints.
|
||||
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
|
||||
plainErr := errors.New("plain failure")
|
||||
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
|
||||
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
err *errs.APIError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "already has source file hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
|
||||
want: "rerun with --type source_file --output <path>",
|
||||
},
|
||||
{
|
||||
name: "candidate core failure empty hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
|
||||
want: drivePreviewSourceFileHint,
|
||||
},
|
||||
{
|
||||
name: "candidate core failure whitespace hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
|
||||
want: drivePreviewSourceFileHint,
|
||||
},
|
||||
{
|
||||
name: "generic server error",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid parameters",
|
||||
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
|
||||
want: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotErr := withDrivePreviewSourceFileHint(tt.err)
|
||||
if gotErr != tt.err {
|
||||
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(gotErr)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
|
||||
}
|
||||
if problem.Hint != tt.want {
|
||||
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
|
||||
// validation error with available alternatives.
|
||||
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
|
||||
@@ -973,21 +721,6 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
|
||||
if path != "cover.pdf" || fallback != nil {
|
||||
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
|
||||
}
|
||||
|
||||
header = http.Header{}
|
||||
header.Set("Content-Type", "text/plain")
|
||||
header.Set("Content-Disposition", `attachment; filename="README.md"`)
|
||||
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
|
||||
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
|
||||
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
|
||||
}
|
||||
|
||||
header = http.Header{}
|
||||
header.Set("Content-Type", "text/plain")
|
||||
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
|
||||
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
|
||||
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
|
||||
@@ -1018,7 +751,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
header := http.Header{}
|
||||
header.Set("Content-Type", "application/pdf")
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
|
||||
}
|
||||
@@ -1026,7 +759,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
|
||||
}
|
||||
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid if-exists error, got nil")
|
||||
}
|
||||
@@ -1038,20 +771,6 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
|
||||
}
|
||||
|
||||
if err := os.Mkdir("artifacts", 0755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
sourceHeader := http.Header{}
|
||||
sourceHeader.Set("Content-Type", "text/plain")
|
||||
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
|
||||
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
|
||||
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
|
||||
}
|
||||
|
||||
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
|
||||
@@ -1060,7 +779,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
|
||||
}
|
||||
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
|
||||
}
|
||||
@@ -1072,7 +791,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
|
||||
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
runtimeWithStatErr.Factory = f
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
|
||||
if err == nil {
|
||||
t.Fatal("expected stat permission error, got nil")
|
||||
}
|
||||
@@ -1157,6 +876,7 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
|
||||
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
|
||||
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
|
||||
}
|
||||
|
||||
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
|
||||
if len(aliases) == 0 || aliases[0] != "image" {
|
||||
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
|
||||
|
||||
@@ -32,7 +32,6 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-limit", 20, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-limit" {
|
||||
continue
|
||||
@@ -64,26 +63,14 @@ func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
for _, name := range []string{"query", "search-types", "chat-modes", "member-ids", "sort", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" || name == "page-limit" {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted", "page-all", "dry-run"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
@@ -343,7 +330,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 100") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -638,7 +625,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
t.Run("ImChatMessageList valid user target", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id": "ou_123",
|
||||
}, map[string]bool{"page-all": false})
|
||||
}, nil)
|
||||
if err := ImChatMessageList.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImChatMessageList.Validate() unexpected error = %v", err)
|
||||
}
|
||||
@@ -701,7 +688,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
t.Run("ImThreadsMessagesList valid omt thread", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "omt_123",
|
||||
}, map[string]bool{"page-all": false})
|
||||
}, nil)
|
||||
if err := ImThreadsMessagesList.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImThreadsMessagesList.Validate() unexpected error = %v", err)
|
||||
}
|
||||
@@ -713,7 +700,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
err := ImMessagesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 50") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") {
|
||||
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -894,7 +881,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "incident",
|
||||
"page-size": "50",
|
||||
"page-size": "51",
|
||||
"page-token": "next_page",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime))
|
||||
@@ -991,7 +978,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImThreadsMessagesList dry run keeps requested thread params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "omt_123",
|
||||
"order": "desc",
|
||||
"sort": "desc",
|
||||
"page-size": "10",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
|
||||
|
||||
@@ -194,8 +194,8 @@ func TestValidateExplicitMsgType(t *testing.T) {
|
||||
func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
t.Run("valid request", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"order": "asc",
|
||||
"page-size": "50",
|
||||
"sort": "asc",
|
||||
"page-size": "80",
|
||||
"page-token": "next",
|
||||
"start": "2026-03-01T00:00:00+08:00",
|
||||
"end": "2026-03-02T23:59:59+08:00",
|
||||
@@ -245,7 +245,7 @@ func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
|
||||
got := buildChatMessageListParams("desc", 20, "oc_123")
|
||||
got := buildChatMessageListParams("desc", "20", "oc_123")
|
||||
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
|
||||
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestBuildMessagesSearchRequest(t *testing.T) {
|
||||
"exclude-sender-type": "bot",
|
||||
"start": "2026-03-01T00:00:00+08:00",
|
||||
"end": "2026-03-02T23:59:59+08:00",
|
||||
"page-size": "50",
|
||||
"page-size": "80",
|
||||
"page-token": "next-token",
|
||||
}, map[string]bool{
|
||||
"at-all": true,
|
||||
@@ -435,7 +435,7 @@ func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) {
|
||||
"query": "team-alpha",
|
||||
"search-types": "private,external",
|
||||
"member-ids": "ou_1,ou_2",
|
||||
"sort": "member_count",
|
||||
"sort-by": "member_count",
|
||||
"page-size": "0",
|
||||
"page-token": "next-page",
|
||||
}, map[string]bool{
|
||||
@@ -452,7 +452,7 @@ func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) {
|
||||
"is_manager": true,
|
||||
"disable_search_by_user": true,
|
||||
},
|
||||
"sorter": "member_count_desc",
|
||||
"sorter": "member_count",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildSearchChatBody() = %#v, want %#v", got, want)
|
||||
|
||||
@@ -82,19 +82,12 @@ func senderDisplay(sender map[string]interface{}) string {
|
||||
}
|
||||
|
||||
func validateMessageID(input string) (string, error) {
|
||||
return validateMessageIDForParam(input, "--message-id")
|
||||
}
|
||||
|
||||
// validateMessageIDForParam validates a message ID and attributes failures to
|
||||
// the command flag that owns the value. Batch inputs use --message-ids while
|
||||
// single-message shortcuts use --message-id.
|
||||
func validateMessageIDForParam(input, param string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam(param)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id")
|
||||
}
|
||||
if !strings.HasPrefix(input, "om_") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam(param)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -206,7 +199,7 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -44,25 +43,6 @@ func (f shortcutRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, err
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type shortcutPolicyDecorator struct {
|
||||
base http.RoundTripper
|
||||
fn shortcutRoundTripFunc
|
||||
}
|
||||
|
||||
func (t *shortcutPolicyDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base
|
||||
}
|
||||
|
||||
func (t *shortcutPolicyDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *shortcutPolicyDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.fn(req)
|
||||
}
|
||||
|
||||
func shortcutJSONResponse(status int, body interface{}) *http.Response {
|
||||
b, _ := json.Marshal(body)
|
||||
return &http.Response{
|
||||
@@ -921,50 +901,6 @@ func TestStartURLDownloadBlockedURLCarriesParam(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartURLDownloadUsesExternalRequestClass(t *testing.T) {
|
||||
platform := &shortcutPolicyDecorator{
|
||||
base: http.DefaultTransport,
|
||||
fn: func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutRawResponse(http.StatusBadGateway, nil, nil), nil
|
||||
},
|
||||
}
|
||||
external := &shortcutPolicyDecorator{
|
||||
base: http.DefaultTransport,
|
||||
fn: func(req *http.Request) (*http.Response, error) {
|
||||
resp := shortcutRawResponse(http.StatusOK, []byte("image"), nil)
|
||||
resp.Request = req
|
||||
return resp, nil
|
||||
},
|
||||
}
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, _, err := startURLDownload(
|
||||
context.Background(),
|
||||
runtime,
|
||||
"https://open.feishu.cn/presigned/image.png",
|
||||
"--image",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("startURLDownload() error = %v, want external route", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(body); got != "image" {
|
||||
t.Fatalf("download body = %q, want external payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLocalMediaImage verifies that resolveLocalMedia can upload an image
|
||||
// via uploadImageToIM without double path validation.
|
||||
func TestResolveLocalMediaImage(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -15,13 +14,8 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
|
||||
imChatListPath = "/open-apis/im/v1/chats"
|
||||
chatListDefaultPageSize = 20
|
||||
// GET /open-apis/im/v1/chats accepts page_size up to 100.
|
||||
chatListMaxPageSize = 100
|
||||
)
|
||||
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
|
||||
const imChatListPath = "/open-apis/im/v1/chats"
|
||||
|
||||
// bot_strip_p2p is the request-level adjustment notice emitted when bot
|
||||
// identity receives a mixed --types containing "p2p": the p2p value is
|
||||
@@ -47,21 +41,20 @@ func writeBotStripP2pWarning(errOut io.Writer) {
|
||||
var ImChatList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only)",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
{Name: "sort-type", Hidden: true, Desc: "legacy API sort vocabulary; use --sort", Enum: legacySortValues(chatListSortCompatibilityValues)},
|
||||
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatListMaxPageSize)},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
}, common.PageAllFlags()...),
|
||||
Normalize: normalizeChatListSortCompatibility,
|
||||
},
|
||||
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
|
||||
// When bot identity strips p2p from --types, emits the same stderr warning
|
||||
// Execute would emit, so DryRun output truthfully reflects what the API
|
||||
@@ -72,22 +65,15 @@ var ImChatList = common.Shortcut{
|
||||
if stripped {
|
||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||
}
|
||||
dry := common.NewDryRunAPI()
|
||||
if runtime.Bool(common.PageAllFlagName) {
|
||||
dry.Desc(pageAllDryRunDescription)
|
||||
}
|
||||
return dry.
|
||||
return common.NewDryRunAPI().
|
||||
GET(imChatListPath).
|
||||
Params(buildChatListParams(runtime, effective))
|
||||
},
|
||||
// Validate enforces flag preconditions: page-size bounds, --types element
|
||||
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", chatListDefaultPageSize, 1, chatListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidatePageAllFlags(runtime); err != nil {
|
||||
return err
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
parts, err := normalizeTypes(runtime.StrSlice("types"))
|
||||
if err != nil {
|
||||
@@ -99,7 +85,7 @@ var ImChatList = common.Shortcut{
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// Execute fetches one or more pages of chats, optionally applies --exclude-muted
|
||||
// Execute fetches one page of chats, optionally applies --exclude-muted
|
||||
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
|
||||
// populated only when --exclude-muted is set (backward compatible).
|
||||
// outData["notices"] is populated only when bot identity strips p2p from
|
||||
@@ -111,24 +97,22 @@ var ImChatList = common.Shortcut{
|
||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||
}
|
||||
params := buildChatListParams(runtime, effective)
|
||||
|
||||
// Fetch stage: one page and --page-all share the same paginator.
|
||||
// The accumulator owns only the endpoint-specific page shape.
|
||||
result := &imMapListResult{}
|
||||
pagination, err := common.PaginateInto(runtime, common.PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: imChatListPath,
|
||||
Params: params,
|
||||
}, result)
|
||||
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Transform stage: filters run once against the complete fetched set, so
|
||||
// their outcome is independent of API page boundaries.
|
||||
items := result.items
|
||||
hasMore := result.hasMore
|
||||
pageToken := result.pageToken
|
||||
rawItems, _ := resData["items"].([]interface{})
|
||||
hasMore, pageToken := common.PaginationMeta(resData)
|
||||
|
||||
var items []map[string]interface{}
|
||||
for _, raw := range rawItems {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
|
||||
ExcludeMuted: runtime.Bool("exclude-muted"),
|
||||
@@ -141,11 +125,7 @@ var ImChatList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
items = mfOut.Chats
|
||||
pagination.Items = len(items)
|
||||
|
||||
// Presentation stage: business data stays backward compatible while the
|
||||
// output layer carries the authoritative pagination outcome for every
|
||||
// format.
|
||||
outData := map[string]interface{}{
|
||||
"chats": items,
|
||||
"has_more": hasMore,
|
||||
@@ -160,9 +140,7 @@ var ImChatList = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{
|
||||
Pagination: pagination,
|
||||
}, func(w io.Writer) {
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No chats found.")
|
||||
if mfOut.Meta.Hint != "" {
|
||||
@@ -202,7 +180,15 @@ var ImChatList = common.Shortcut{
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d chat(s) listed\n", len(rows))
|
||||
fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
|
||||
if hasMore {
|
||||
fmt.Fprint(w, " (more available, use --page-token to fetch next page")
|
||||
if pageToken != "" {
|
||||
fmt.Fprintf(w, ", page_token: %s", pageToken)
|
||||
}
|
||||
fmt.Fprint(w, ")")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
@@ -285,6 +271,9 @@ func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string)
|
||||
"create_time": "ByCreateTimeAsc",
|
||||
"active_time": "ByActiveTimeDesc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
|
||||
sortType = old // old value is already the upstream enum -> pass through
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"sort_type": sortType,
|
||||
|
||||
@@ -30,13 +30,8 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().String("sort", "create_time", "")
|
||||
cmd.Flags().String("sort-type", "", "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" || name == "page-limit" || name == "sort" || name == "sort-type" {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
if name == "types" {
|
||||
@@ -46,9 +41,6 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
|
||||
}
|
||||
}
|
||||
for name := range boolFlags {
|
||||
if name == "page-all" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
@@ -75,9 +67,6 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
|
||||
ErrOut: &bytes.Buffer{},
|
||||
},
|
||||
}
|
||||
if err := normalizeChatListSortCompatibility(context.Background(), rt.FlagContext()); err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
@@ -307,14 +296,10 @@ func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
cmd.Flags().String("user-id-type", "open_id", "")
|
||||
cmd.Flags().String("sort", "create_time", "")
|
||||
cmd.Flags().String("sort-type", "", "")
|
||||
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
|
||||
cmd.Flags().StringSlice("types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Bool("exclude-muted", false, "")
|
||||
cmd.Flags().Bool("dry-run", false, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
@@ -331,9 +316,6 @@ func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
if err := normalizeChatListSortCompatibility(context.Background(), runtime.FlagContext()); err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
runtime.Format = "json"
|
||||
}
|
||||
|
||||
@@ -453,8 +435,8 @@ func TestImChatList_RowRendering_P2pFields(t *testing.T) {
|
||||
|
||||
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
|
||||
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
|
||||
// surfaces p2p_target_type / p2p_target_id, plus the shared pagination
|
||||
// summary that carries the resume token.
|
||||
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
|
||||
// echoes back the page_token.
|
||||
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := `{"code":0,"msg":"ok","data":{"items":[
|
||||
@@ -488,10 +470,8 @@ func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
|
||||
if !strings.Contains(out, "2 chat(s) listed") {
|
||||
t.Fatalf("pretty output missing footer count:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"Pagination: incomplete", `resume token: "next_tok"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing pagination summary %q:\n%s", want, out)
|
||||
}
|
||||
if !strings.Contains(out, "next_tok") {
|
||||
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,13 +591,6 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
FilteredCount int `json:"filtered_count"`
|
||||
} `json:"filter"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Pagination struct {
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
|
||||
@@ -637,9 +610,6 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
|
||||
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
|
||||
}
|
||||
if !parsed.Meta.Pagination.Complete || parsed.Meta.Pagination.Pages != 1 || parsed.Meta.Pagination.Items != 1 {
|
||||
t.Fatalf("pagination meta = %+v; want one complete page and one emitted item", parsed.Meta.Pagination)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatList_SortMapping(t *testing.T) {
|
||||
@@ -658,9 +628,9 @@ func TestChatList_SortMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortCompatibilityParity proves Normalize maps the hidden legacy
|
||||
// vocabulary to the same canonical request (byte-equal).
|
||||
func TestChatList_SortCompatibilityParity(t *testing.T) {
|
||||
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
|
||||
// exact same upstream request as the equivalent new --sort value (byte-equal).
|
||||
func TestChatList_SortAliasParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "ByCreateTimeAsc"},
|
||||
{"active_time", "ByActiveTimeDesc"},
|
||||
@@ -692,16 +662,16 @@ func TestChatList_SortNewWins(t *testing.T) {
|
||||
|
||||
// TestChatList_SortFlagSurface asserts the declared flag structure.
|
||||
func TestChatList_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, legacyFlag *common.Flag
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatList.Flags {
|
||||
switch ImChatList.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatList.Flags[i]
|
||||
case "sort-type":
|
||||
legacyFlag = &ImChatList.Flags[i]
|
||||
aliasFlag = &ImChatList.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || legacyFlag == nil {
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-type flags declared")
|
||||
}
|
||||
if sortFlag.Default != "create_time" {
|
||||
@@ -713,13 +683,13 @@ func TestChatList_SortFlagSurface(t *testing.T) {
|
||||
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
|
||||
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
|
||||
}
|
||||
if !legacyFlag.Hidden {
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort-type must be Hidden")
|
||||
}
|
||||
if got := strings.Join(legacyFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
||||
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
|
||||
}
|
||||
if legacyFlag.Default != "" {
|
||||
t.Errorf("--sort-type compatibility flag must not carry a Default, got %q", legacyFlag.Default)
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user