mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
6 Commits
feat/slide
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a0a9de66 | ||
|
|
a8ad44ba13 | ||
|
|
003d0f42f8 | ||
|
|
7946e5c81d | ||
|
|
5cf09ecfda | ||
|
|
41692b7041 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
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
|
||||
@@ -1722,6 +1751,7 @@ 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,10 +310,6 @@ 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,10 +311,6 @@ 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)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
// Registration is platform traffic, so it must use the provider-aware
|
||||
// transport as well as the shared proxy configuration.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
// Connectivity checks are platform traffic and must exercise the same
|
||||
// provider-aware route as real platform requests.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
|
||||
@@ -12,9 +12,18 @@ 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
|
||||
@@ -263,3 +272,55 @@ 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,6 +15,27 @@ 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,6 +17,8 @@ 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 {
|
||||
@@ -31,6 +33,16 @@ 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,6 +212,9 @@ 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,6 +518,29 @@ 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,10 +16,12 @@ 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.
|
||||
@@ -31,7 +33,7 @@ type InvocationContext struct {
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
@@ -48,6 +50,18 @@ 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,16 +5,18 @@ 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"
|
||||
@@ -48,6 +50,19 @@ 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.
|
||||
@@ -55,7 +70,6 @@ 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)
|
||||
@@ -87,15 +101,45 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
|
||||
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
|
||||
// redirects; other headers like X-Cli-* pass through.
|
||||
// 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.
|
||||
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
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) {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("X-Lark-MCP-UAT")
|
||||
req.Header.Del("X-Lark-MCP-TAT")
|
||||
@@ -103,6 +147,29 @@ 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
|
||||
@@ -118,15 +185,12 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
|
||||
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)
|
||||
shared := transport.Shared()
|
||||
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
|
||||
platform := buildDirectHTTPTransport(outbound, true)
|
||||
external := buildDirectHTTPTransport(outbound, false)
|
||||
client := &http.Client{
|
||||
Transport: rt,
|
||||
Transport: transport.NewHTTPPolicyRouter(platform, external),
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -134,6 +198,15 @@ 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())
|
||||
@@ -149,14 +222,8 @@ 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: sdkTransport,
|
||||
Transport: buildSDKTransport(hostSignalSource),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -165,12 +232,41 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
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
|
||||
@@ -33,7 +40,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
|
||||
@@ -44,7 +51,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
|
||||
@@ -54,3 +61,283 @@ 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,14 +4,19 @@
|
||||
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 {
|
||||
@@ -27,6 +32,16 @@ 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
|
||||
@@ -63,6 +78,19 @@ 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())
|
||||
@@ -73,14 +101,25 @@ 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. 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.
|
||||
// X-Cli-Build header before every request. It remains in the SDK transport
|
||||
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
|
||||
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())
|
||||
@@ -103,6 +142,16 @@ 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())
|
||||
@@ -120,67 +169,3 @@ 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,94 +91,107 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapSDKTransport chain composition
|
||||
// buildSDKTransport policy behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
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)
|
||||
|
||||
// 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)
|
||||
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 TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
resp.Body.Close()
|
||||
if !interceptor.preCalled || !interceptor.postCalled {
|
||||
t.Fatal("SDK platform request did not execute extension pre/post hooks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
|
||||
t.Fatalf(
|
||||
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
|
||||
buildSDKTransport(nil),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
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)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
rebuilt, concrete, ok := source.CloneHTTPTransport()
|
||||
if !ok || rebuilt == nil || concrete == nil {
|
||||
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
|
||||
}
|
||||
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)
|
||||
if concrete == base {
|
||||
t.Fatal("SDK policy graph reused the original HTTP transport")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +251,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -266,14 +279,16 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -285,7 +300,74 @@ func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
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) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
@@ -301,7 +383,11 @@ func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -312,14 +398,13 @@ func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
// closes the gap where the SDK chain had no equivalent of
|
||||
// SecurityHeaderTransport (see design doc §3.3.3).
|
||||
// SDK chain restores both the build classification and the full security
|
||||
// header set after an extension runs.
|
||||
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
var receivedBuild string
|
||||
var receivedBuild, receivedSource 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()
|
||||
@@ -327,12 +412,13 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
// Replicate the SDK built-in chain inside buildSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -349,6 +435,9 @@ 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
|
||||
@@ -438,7 +527,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
@@ -500,7 +589,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -541,7 +630,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
_, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -560,7 +649,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -63,3 +66,39 @@ 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,7 +3,11 @@
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
@@ -91,3 +95,85 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,4 @@ registry.npmjs.org
|
||||
registry.npmmirror.com
|
||||
sf16-sg.tiktokcdn.com
|
||||
www.feishu.cn
|
||||
www.larkoffice.com
|
||||
www.larksuite.com
|
||||
|
||||
@@ -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) {
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
// Remote metadata is platform traffic and must honor both the shared proxy
|
||||
// configuration and the registered platform transport extension.
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
@@ -40,6 +42,28 @@ 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/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), 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
|
||||
|
||||
258
internal/transport/default_client.go
Normal file
258
internal/transport/default_client.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// 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)
|
||||
}
|
||||
120
internal/transport/extension.go
Normal file
120
internal/transport/extension.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// 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)
|
||||
}
|
||||
924
internal/transport/extension_test.go
Normal file
924
internal/transport/extension_test.go
Normal file
@@ -0,0 +1,924 @@
|
||||
// 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)
|
||||
}
|
||||
232
internal/transport/policy_router.go
Normal file
232
internal/transport/policy_router.go
Normal file
@@ -0,0 +1,232 @@
|
||||
// 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
|
||||
}
|
||||
351
internal/transport/policy_router_test.go
Normal file
351
internal/transport/policy_router_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// 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,6 +8,8 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
@@ -55,21 +57,29 @@ func Fallback() *http.Transport {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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: Shared(),
|
||||
Transport: NewHTTPPolicyRouter(base, base),
|
||||
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,23 +88,24 @@ func TestShared_NoProxyOverridesSystemProxy(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) {
|
||||
// TestHTTPClientConstructors verifies both the policy-routed client and its
|
||||
// forced-external view retain explicit transports and configured timeouts.
|
||||
func TestHTTPClientConstructors(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,4 +154,32 @@ 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,10 +62,7 @@ func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
return transport.NewExternalHTTPClient(fetchTimeout)
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
@@ -19,6 +22,30 @@ 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) {
|
||||
@@ -242,6 +269,46 @@ 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,11 +5,15 @@ package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +38,9 @@ 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
|
||||
}
|
||||
@@ -52,6 +59,9 @@ 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() {
|
||||
@@ -76,32 +86,42 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are supported")
|
||||
}
|
||||
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
|
||||
_, 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))
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL host is required")
|
||||
return nil, fmt.Errorf("URL host is required")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if lookupIP == nil {
|
||||
lookupIP = net.DefaultResolver.LookupIP
|
||||
}
|
||||
ips, err := lookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
|
||||
@@ -115,7 +135,10 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
cloned.Transport = cloneDownloadTransport(base.Transport)
|
||||
cloned.Transport = &downloadSchemeTransport{
|
||||
base: cloneDownloadTransport(base.Transport),
|
||||
allowHTTP: opts.AllowHTTP,
|
||||
}
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= opts.MaxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
@@ -138,18 +161,310 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
return &cloned
|
||||
}
|
||||
|
||||
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{}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -158,7 +473,7 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -172,13 +487,26 @@ func cloneDownloadTransport(base http.RoundTripper) *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.
|
||||
@@ -194,7 +522,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -208,6 +536,14 @@ 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")
|
||||
|
||||
529
internal/validate/url_internal_test.go
Normal file
529
internal/validate/url_internal_test.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// 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()
|
||||
}
|
||||
222
internal/validate/url_test.go
Normal file
222
internal/validate/url_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
71
shortcuts/apps/apps_cache_clear.go
Normal file
71
shortcuts/apps/apps_cache_clear.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheClear clears all cache entries for the app in the given environment.
|
||||
//
|
||||
// POST /apps/{app_id}/cache/clear,body {env}。清空当前应用指定环境下全部缓存,用于无法定位
|
||||
// 具体 key 的快速恢复;影响面大,定 high-risk-write(框架自动注入 --yes 确认)。
|
||||
var AppsCacheClear = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-clear",
|
||||
Description: "Clear all cache entries for the app in the given environment",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appCacheClearPath(appID)).
|
||||
Desc("Clear all cache entries for the app in the given environment").
|
||||
Body(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheClearPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
|
||||
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
|
||||
n := int64(0)
|
||||
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
|
||||
n = int64(f)
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
|
||||
}
|
||||
75
shortcuts/apps/apps_cache_delete.go
Normal file
75
shortcuts/apps/apps_cache_delete.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheDelete deletes a single business cache key (idempotent).
|
||||
//
|
||||
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
|
||||
// 故定 write(非 high-risk-write、不需 --yes)。目标不存在按幂等成功处理(deleted_key_count=0)。
|
||||
var AppsCacheDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-delete",
|
||||
Description: "Delete a single business cache key (idempotent)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
DELETE(appCachePath(appID)).
|
||||
Desc("Delete a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheDeletePretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
|
||||
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
|
||||
key := common.GetString(out, "key")
|
||||
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
|
||||
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
|
||||
}
|
||||
105
shortcuts/apps/apps_cache_get.go
Normal file
105
shortcuts/apps/apps_cache_get.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheGet reads a single business cache key's value + metadata.
|
||||
//
|
||||
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
|
||||
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
|
||||
// 按 value 字节长度算出(端点不返回);未命中(exists=false)时不带 value,ttl_ms/value_size_bytes 为 null。
|
||||
var AppsCacheGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-get",
|
||||
Description: "Get a business cache key's value and metadata",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appCachePath(appID)).
|
||||
Desc("Get a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectCacheGet(data, key, rctx)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectCacheGet 组装 cache-get 输出:key 回显、environment 取 resolved env、exists 直读;
|
||||
// 命中时带 ttl_ms + value(原始串)+ value_size_bytes(CLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
|
||||
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
|
||||
exists := cacheBool(data["exists"])
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"exists": exists,
|
||||
}
|
||||
if exists {
|
||||
val := common.GetString(data, "value")
|
||||
out["ttl_ms"] = cacheInt(data["ttl_ms"])
|
||||
out["value_size_bytes"] = len([]byte(val))
|
||||
out["value"] = val
|
||||
} else {
|
||||
out["ttl_ms"] = nil
|
||||
out["value_size_bytes"] = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderCacheGetPretty 打元信息块(key/environment/exists,命中再加 ttl/value_size),命中时末尾展开 value。
|
||||
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
exists, _ := out["exists"].(bool)
|
||||
pairs := [][2]string{
|
||||
{"key", common.GetString(out, "key")},
|
||||
{"environment", common.GetString(out, "environment")},
|
||||
{"exists", fmt.Sprintf("%v", exists)},
|
||||
}
|
||||
if exists {
|
||||
pairs = append(pairs,
|
||||
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
|
||||
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
|
||||
)
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
if exists {
|
||||
fmt.Fprintln(w, "value:")
|
||||
printCacheValuePretty(w, common.GetString(out, "value"))
|
||||
}
|
||||
}
|
||||
357
shortcuts/apps/apps_cache_test.go
Normal file
357
shortcuts/apps/apps_cache_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
|
||||
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
|
||||
)
|
||||
|
||||
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串(value 不反序列化)。
|
||||
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
|
||||
|
||||
// ── cache-get ──
|
||||
|
||||
// TestAppsCacheGet_HitJSON:命中时 json 默认——value 原样透传(不反序列化),
|
||||
// value_size_bytes 由 CLI 按 value 字节长度算出,environment 取服务端 resolved env。
|
||||
func TestAppsCacheGet_HitJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
|
||||
t.Fatalf("get hit data=%v", d)
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
|
||||
}
|
||||
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
|
||||
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
|
||||
}
|
||||
// ttl_ms 必须是 JSON number(透传服务端数字,不得变成字符串);JSON 解析后为 float64。
|
||||
if _, ok := d["ttl_ms"].(float64); !ok {
|
||||
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_HitPretty:pretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
|
||||
func TestAppsCacheGet_HitPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_Miss:未命中——exists=false,无 value,ttl_ms / value_size_bytes 为 null。
|
||||
func TestAppsCacheGet_Miss(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": false,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != false {
|
||||
t.Fatalf("miss exists=%v", d["exists"])
|
||||
}
|
||||
if _, ok := d["value"]; ok {
|
||||
t.Fatalf("miss must not carry value: %v", d)
|
||||
}
|
||||
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
|
||||
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_ExistsAsString:服务端把 exists 返成字符串 "true" 时仍按命中处理
|
||||
// (cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
|
||||
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != true {
|
||||
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("命中应带 value, got %v", d["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_PrettyNonJSONFallback:pretty 下 value 不是合法 JSON 时降级原样输出
|
||||
// (safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
|
||||
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
|
||||
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_TTLAsStringNormalized:服务端把 ttl_ms 返成字符串 "272000" 时,
|
||||
// 输出的 ttl_ms 必须归一成 JSON number(cacheInt),不得随 wire 形态漂移成字符串。
|
||||
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
f, ok := d["ttl_ms"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
if int(f) != 272000 {
|
||||
t.Fatalf("ttl_ms = %v, want 272000", f)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_CountAsStringNormalized:服务端把 deleted_key_count 返成字符串 "1" 时,
|
||||
// 输出必须归一成 JSON number(cacheInt)。
|
||||
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if _, ok := d["deleted_key_count"].(float64); !ok {
|
||||
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunOmitsEnv:不传 --environment 时 dry-run query 不带 env(服务端自动选),但带 key。
|
||||
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "GET" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
|
||||
}
|
||||
if a.Params["key"] != "k:1" {
|
||||
t.Fatalf("key must be in query, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunWithEnv:显式 --environment dev → query 带 env=dev。
|
||||
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Params["env"] != "dev" {
|
||||
t.Fatalf("env must be dev, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_RequiresKey:缺 --key → 校验错。
|
||||
func TestAppsCacheGet_RequiresKey(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --key error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-delete ──
|
||||
|
||||
// TestAppsCacheDelete_Hit:删中命中的 key → deleted_key_count=1;pretty 打 "✓ cache deleted"。
|
||||
func TestAppsCacheDelete_Hit(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache deleted") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentJSON:目标不存在 → 幂等成功,deleted_key_count=0,pretty 措辞区分。
|
||||
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
|
||||
t.Fatalf("absent data=%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentPretty:不存在 pretty 打 "✓ cache already absent"。
|
||||
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "already absent") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_DryRun:DELETE 方法、/cache 路由,query 带 key + env。
|
||||
func TestAppsCacheDelete_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "DELETE" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
|
||||
t.Fatalf("params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-clear ──
|
||||
|
||||
// TestAppsCacheClear_Success:清空成功 → deleted_key_count=128;pretty 打 "✓ cache cleared: 128 entries (dev)"。
|
||||
func TestAppsCacheClear_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: cacheClearURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_RequiresConfirmation:high-risk-write 无 --yes → 被确认门拦截。
|
||||
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyWithEnv:dry-run POST /cache/clear,body 带 env=dev。
|
||||
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "POST" || a.URL != cacheClearURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["env"] != "dev" {
|
||||
t.Fatalf("body must carry env=dev, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyOmitsEnv:不传 --environment → body 不带 env(服务端自动选)。
|
||||
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if _, ok := a.Body["env"]; ok {
|
||||
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项(method/url/params/body)。
|
||||
// 复用本包规范的 dryRunAPIEnvelope(api 现嵌在 data.api 下,见 dryrun_test.go)。
|
||||
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
|
||||
t.Helper()
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
|
||||
t.Fatalf("bad dry-run json: %v\n%s", err, s)
|
||||
}
|
||||
return env.API[0]
|
||||
}
|
||||
99
shortcuts/apps/cache_common.go
Normal file
99
shortcuts/apps/cache_common.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 应用运行时缓存(Cache)调试命令共享件:路由 + 环境 flag + 渲染。
|
||||
//
|
||||
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`,按运行环境(env→dbBranch)隔离:
|
||||
// 环境 flag 用 cacheEnvFlag()(只 --environment,不带 db 家族的旧名 --env),env 值经 dbEnv 读、
|
||||
// 经 dbEnvParams 注入——get/delete 放 query,clear 放 body(省略即服务端自动选分支)。
|
||||
|
||||
// appCachePath 返回缓存单 key 读/删 URL:cache(GET 读、DELETE 删,靠方法区分)。
|
||||
func appCachePath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appCacheClearPath 返回清空指定环境缓存 URL:cache/clear。
|
||||
func appCacheClearPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env,
|
||||
// 故只注册干净的 --environment(不带 db 家族那套隐藏 --env + 拒收逻辑)。
|
||||
// 省略即服务端按应用多环境状态自动选分支(多环境→dev,非多环境→online)。
|
||||
func cacheEnvFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "environment",
|
||||
Enum: []string{"dev", "online"},
|
||||
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
|
||||
}
|
||||
}
|
||||
|
||||
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool,
|
||||
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中(hit→miss 翻转)。
|
||||
func cacheBool(v interface{}) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(x), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cacheInt 把服务端下发的数值字段归一成 int64(无法解析→nil)。本仓惯例:数值可能以字符串下发
|
||||
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
|
||||
// (number ↔ string)。归一后输出类型恒定为数字或 null,消费方无需自己容忍字符串。
|
||||
func cacheInt(raw interface{}) interface{} {
|
||||
if f, ok := numericAsFloat(raw); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedEnv 取服务端回吐的 resolved env;缺失时兜底成请求侧 --environment(可能为空)。
|
||||
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
|
||||
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
|
||||
if env := common.GetString(data, "env"); env != "" {
|
||||
return env
|
||||
}
|
||||
return dbEnv(rctx)
|
||||
}
|
||||
|
||||
// formatCacheTTL 把剩余 TTL(毫秒)格式化成 4m32s 这样的时长串;非数字返回 "—"。
|
||||
func formatCacheTTL(ms interface{}) string {
|
||||
f, ok := numericAsFloat(ms)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
return (time.Duration(int64(f)) * time.Millisecond).String()
|
||||
}
|
||||
|
||||
// printCacheValuePretty 把 value 反序列化后缩进展开(pretty 口径);非 JSON 则原样打印。
|
||||
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
|
||||
func printCacheValuePretty(w io.Writer, raw string) {
|
||||
v := safeParseJSON(raw)
|
||||
if s, ok := v.(string); ok {
|
||||
fmt.Fprintln(w, s)
|
||||
return
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, raw)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
)
|
||||
@@ -74,11 +75,9 @@ 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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
//nolint:forbidigo // Presigned transfers use the external HTTP policy.
|
||||
func newFileTransferClient() *http.Client {
|
||||
return &http.Client{Transport: http.DefaultTransport}
|
||||
return transport.NewExternalHTTPClient(0)
|
||||
}
|
||||
|
||||
// URL helpers for the file (storage) CLI commands.
|
||||
|
||||
79
shortcuts/apps/file_common_test.go
Normal file
79
shortcuts/apps/file_common_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsFileUpload,
|
||||
AppsFileDelete,
|
||||
AppsFileQuotaGet,
|
||||
AppsCacheGet,
|
||||
AppsCacheDelete,
|
||||
AppsCacheClear,
|
||||
AppsGitCredentialInit,
|
||||
AppsGitCredentialList,
|
||||
AppsGitCredentialRemove,
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 cache(get/delete/clear)
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。
|
||||
func TestAppsShortcuts_Returns82(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 79 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
||||
if len(got) != 82 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -27,19 +28,23 @@ 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 {
|
||||
api := common.NewDryRunAPI().
|
||||
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return 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"))
|
||||
// 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
|
||||
Set("form_id", runtime.Str("form-id")).
|
||||
Body(map[string]interface{}{"questions": questions})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
formId := runtime.Str("form-id")
|
||||
questionsJSON := runtime.Str("questions")
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
questions, err := parseFormQuestionsCreate(questionsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
@@ -78,3 +83,31 @@ 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
|
||||
}
|
||||
|
||||
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,6 @@ 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",
|
||||
@@ -542,7 +536,7 @@ func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, ra
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
baseClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -673,6 +667,9 @@ 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
|
||||
}
|
||||
@@ -701,13 +698,15 @@ 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.HttpClient.
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.ExternalHTTPClient.
|
||||
}
|
||||
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 = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
|
||||
cloned.Transport = validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{ //nolint:forbidigo // guarded external download
|
||||
MaxRedirects: 3,
|
||||
}).Transport
|
||||
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")
|
||||
@@ -723,73 +722,3 @@ 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,6 +386,7 @@ 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",
|
||||
@@ -406,17 +407,26 @@ func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
|
||||
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
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
|
||||
},
|
||||
}
|
||||
baseClient := &http.Client{Transport: baseTransport}
|
||||
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://203.0.113.10/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("cover URL downloader must not inherit proxy settings")
|
||||
if _, err := client.Transport.RoundTrip(req); !errors.Is(err, proxyErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want proxy policy error %v", err, proxyErr)
|
||||
}
|
||||
if baseTransport.Proxy == nil {
|
||||
t.Fatal("base transport proxy was mutated")
|
||||
@@ -446,21 +456,6 @@ 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
|
||||
@@ -662,16 +657,6 @@ 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) {
|
||||
@@ -710,3 +695,61 @@ 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 wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -21,6 +23,30 @@ 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,6 +1580,84 @@ 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: "List or download available preview artifacts for a Drive file",
|
||||
Description: "View or download Drive file content, or list and fetch available preview artifacts",
|
||||
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"},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
|
||||
{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,6 +40,25 @@ 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
|
||||
@@ -67,7 +86,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", runtime.Str("type")).
|
||||
Set("requested_type", requestedType).
|
||||
Set("output", runtime.Str("output"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -82,9 +101,25 @@ 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,6 +27,8 @@ 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 {
|
||||
@@ -88,7 +90,9 @@ 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": "",
|
||||
@@ -464,7 +468,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -492,8 +496,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 string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
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)
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
@@ -522,6 +526,32 @@ 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) {
|
||||
@@ -556,6 +586,15 @@ 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
|
||||
}
|
||||
@@ -804,6 +843,36 @@ 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,6 +147,63 @@ 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) {
|
||||
@@ -434,6 +491,72 @@ 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) {
|
||||
@@ -612,6 +735,135 @@ 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) {
|
||||
@@ -721,6 +973,21 @@ 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
|
||||
@@ -751,7 +1018,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)
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
|
||||
}
|
||||
@@ -759,7 +1026,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
|
||||
}
|
||||
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid if-exists error, got nil")
|
||||
}
|
||||
@@ -771,6 +1038,20 @@ 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)
|
||||
@@ -779,7 +1060,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
|
||||
}
|
||||
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
|
||||
}
|
||||
@@ -791,7 +1072,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)
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
|
||||
if err == nil {
|
||||
t.Fatal("expected stat permission error, got nil")
|
||||
}
|
||||
@@ -876,7 +1157,6 @@ 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)
|
||||
|
||||
@@ -199,7 +199,7 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -43,6 +44,25 @@ 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{
|
||||
@@ -901,6 +921,50 @@ 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) {
|
||||
|
||||
@@ -1790,7 +1790,7 @@ func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL strin
|
||||
return nil, mailInvalidResponseError("attachment download URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -396,6 +398,36 @@ func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAttachmentContentUsesExternalRequestClass(t *testing.T) {
|
||||
platform := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
external := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("attachment data")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
rt := newDownloadRuntime(t, &http.Client{
|
||||
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
|
||||
})
|
||||
|
||||
data, err := downloadAttachmentContent(rt, "https://open.feishu.cn/presigned/file")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAttachmentContent() error = %v, want external route", err)
|
||||
}
|
||||
if got := string(data); got != "attachment data" {
|
||||
t.Fatalf("downloadAttachmentContent() = %q, want external payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// newOutputRuntime — helper for tests that call runtime.Out / runtime.IO()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -228,7 +228,7 @@ func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filenam
|
||||
return nil, "", mailInvalidResponseError("signature image download: URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@ package mail
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -115,6 +119,47 @@ func TestContentTypeFromFilename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageUsesExternalRequestClass(t *testing.T) {
|
||||
const payload = `{"code":21000,"msg":"application-defined response","data":{"cli_hint":"external"}}`
|
||||
platform := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
external := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(payload)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client := &http.Client{Transport: internaltransport.NewHTTPPolicyRouter(
|
||||
&auth.SecurityPolicyTransport{Base: platform},
|
||||
external,
|
||||
)}
|
||||
factory := &cmdutil.Factory{
|
||||
HttpClient: func() (*http.Client, error) { return client, nil },
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{}, nil)
|
||||
runtime.Factory = factory
|
||||
|
||||
data, contentType, err := downloadSignatureImage(
|
||||
runtime,
|
||||
"https://open.feishu.cn/signature.png",
|
||||
"signature.png",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadSignatureImage() error = %v, want external response passthrough", err)
|
||||
}
|
||||
if string(data) != payload || contentType != "application/json" {
|
||||
t.Fatalf("download = %q (%s), want external response", data, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureCIDsNilSig(t *testing.T) {
|
||||
if cids := signatureCIDs(nil); cids != nil {
|
||||
t.Fatalf("expected nil slice for nil sig, got %v", cids)
|
||||
@@ -206,6 +251,12 @@ func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[st
|
||||
})
|
||||
}
|
||||
|
||||
type signatureRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f signatureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
|
||||
markdownUploadFinishAction = "finalize markdown multipart upload failed"
|
||||
markdownFetchNameAction = "fetch existing markdown file name failed"
|
||||
markdownSourceFilePreviewType = "16"
|
||||
)
|
||||
|
||||
var markdownUploadRetryBackoffs = []time.Duration{
|
||||
@@ -192,9 +193,14 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
|
||||
}
|
||||
|
||||
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
|
||||
query, err := markdownSourceFilePreviewQuery("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapMarkdownDownloadError(err)
|
||||
@@ -230,15 +236,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
|
||||
query, err := markdownSourceFilePreviewQuery(version, versionParam)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
req.QueryParams = larkcore.QueryParams{
|
||||
"version": []string{strings.TrimSpace(version)},
|
||||
}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: query,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, req)
|
||||
@@ -248,6 +254,58 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
|
||||
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
|
||||
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := larkcore.QueryParams{
|
||||
"preview_type": []string{markdownSourceFilePreviewType},
|
||||
}
|
||||
if version != "" {
|
||||
query["version"] = []string{version}
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
|
||||
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"preview_type": markdownSourceFilePreviewType,
|
||||
}
|
||||
if version != "" {
|
||||
params["version"] = version
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
|
||||
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
|
||||
if err != nil {
|
||||
// Shortcut validation runs before DryRun. If a caller bypasses that
|
||||
// contract, preserve the supplied value instead of silently dropping it.
|
||||
params = map[string]interface{}{
|
||||
"preview_type": markdownSourceFilePreviewType,
|
||||
"version": version,
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
return nil
|
||||
}
|
||||
if flagName == "" {
|
||||
flagName = "--version"
|
||||
}
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
|
||||
@@ -112,9 +112,8 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
|
||||
}
|
||||
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
|
||||
return err
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
|
||||
@@ -134,31 +133,33 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
if spec.FromVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the specified remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the specified remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the latest remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
|
||||
}
|
||||
dry.Set("local_file", spec.FilePath)
|
||||
dry.Set("mode", markdownDiffModeRemoteVsLocal)
|
||||
default:
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the base remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the base remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
|
||||
if spec.ToVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the target remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the target remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.ToVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the latest remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
|
||||
}
|
||||
dry.Set("mode", markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
@@ -166,8 +167,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
return dry
|
||||
}
|
||||
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -446,8 +447,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
Format: runtime.Format,
|
||||
@@ -456,8 +457,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return markdownDiffDryRun(markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
})
|
||||
@@ -465,8 +466,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
}
|
||||
@@ -487,7 +488,7 @@ var MarkdownDiff = common.Shortcut{
|
||||
} else {
|
||||
fromLabel += "@latest"
|
||||
}
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -499,17 +500,17 @@ var MarkdownDiff = common.Shortcut{
|
||||
}
|
||||
default:
|
||||
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.ToVersion != "" {
|
||||
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
|
||||
} else {
|
||||
toLabel = "b/" + spec.FileToken + "@latest"
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -48,6 +48,73 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "from version",
|
||||
args: []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", " \t",
|
||||
"--file", "./local.md",
|
||||
},
|
||||
wantParam: "--from-version",
|
||||
},
|
||||
{
|
||||
name: "to version",
|
||||
args: []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", " ",
|
||||
},
|
||||
wantParam: "--to-version",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
|
||||
requireMarkdownValidationParam(t, err, tt.wantParam)
|
||||
if !strings.Contains(err.Error(), "cannot be empty") {
|
||||
t.Fatalf("expected empty version validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
|
||||
version := " 7633658129540910621 "
|
||||
|
||||
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
|
||||
if err != nil {
|
||||
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
|
||||
}
|
||||
if got := query["version"]; len(got) != 1 || got[0] != version {
|
||||
t.Fatalf("query version = %#v, want original %q", got, version)
|
||||
}
|
||||
|
||||
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
|
||||
if err != nil {
|
||||
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
|
||||
}
|
||||
if got := params["version"]; got != version {
|
||||
t.Fatalf("dry-run version = %#v, want original %q", got, version)
|
||||
}
|
||||
|
||||
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
|
||||
requireMarkdownValidationParam(t, err, "--from-version")
|
||||
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
|
||||
requireMarkdownValidationParam(t, err, "--to-version")
|
||||
}
|
||||
|
||||
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
@@ -79,7 +146,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
|
||||
Headers: http.Header{
|
||||
@@ -88,7 +155,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
|
||||
Headers: http.Header{
|
||||
@@ -151,7 +218,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\nhello old\n"),
|
||||
Headers: http.Header{
|
||||
@@ -191,7 +258,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
|
||||
})
|
||||
@@ -218,7 +285,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
@@ -337,7 +404,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
|
||||
Headers: http.Header{
|
||||
@@ -346,7 +413,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
|
||||
Headers: http.Header{
|
||||
@@ -398,13 +465,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
@@ -445,8 +512,11 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
|
||||
t.Fatalf("dry-run missing download call: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
|
||||
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
|
||||
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
|
||||
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
|
||||
|
||||
@@ -5,14 +5,10 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -47,8 +43,9 @@ var MarkdownFetch = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
|
||||
Set("file_token", runtime.Str("file-token"))
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath)
|
||||
@@ -61,12 +58,9 @@ var MarkdownFetch = common.Shortcut{
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
|
||||
if err != nil {
|
||||
return wrapMarkdownDownloadError(err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ var MarkdownPatch = common.Shortcut{
|
||||
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the current Markdown content").
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the current Markdown source file preview artifact").
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
|
||||
Set("file_token", spec.FileToken).
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
|
||||
|
||||
@@ -85,9 +85,12 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
|
||||
if got := len(dry.API); got != 6 {
|
||||
t.Fatalf("api steps = %d, want 6", got)
|
||||
}
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
|
||||
t.Fatalf("download url = %q", got)
|
||||
}
|
||||
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
|
||||
t.Fatalf("download preview_type = %#v", got)
|
||||
}
|
||||
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metas url = %q", got)
|
||||
}
|
||||
@@ -120,7 +123,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
|
||||
if got := dry.Mode; got != markdownPatchModeRegex {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
|
||||
}
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
|
||||
t.Fatalf("download desc = %q", got)
|
||||
}
|
||||
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
|
||||
@@ -144,7 +147,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
@@ -187,7 +190,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
@@ -224,7 +227,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\nTODO\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -299,7 +302,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -360,7 +363,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("Version: 12\nVersion: 34\n"),
|
||||
})
|
||||
@@ -429,7 +432,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello world\n"),
|
||||
})
|
||||
@@ -478,7 +481,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello\n"),
|
||||
})
|
||||
@@ -509,9 +512,10 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
|
||||
type markdownPatchDryRunOutput struct {
|
||||
Mode string `json:"mode"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
|
||||
@@ -145,13 +145,8 @@ var MinutesDownload = common.Shortcut{
|
||||
seen := make(map[string]int)
|
||||
usedNames := make(map[string]bool)
|
||||
|
||||
// Clone the factory client for download use. We clone the struct (not the
|
||||
// pointer) to avoid mutating the shared singleton's Timeout. The original
|
||||
// transport chain is preserved so security headers and test mocks still work.
|
||||
// SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect
|
||||
// (redirect-level). Transport-level IP check is intentionally omitted because
|
||||
// download URLs originate from the trusted Lark API, not user input.
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
// Clone the external client so timeout changes stay local.
|
||||
baseClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -30,6 +32,12 @@ import (
|
||||
|
||||
var warmOnce sync.Once
|
||||
|
||||
type minutesRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f minutesRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func warmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
warmOnce.Do(func() {
|
||||
@@ -216,6 +224,52 @@ func TestDownload_ServerFilenameTraversalStaysInOutputDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadUsesExternalRequestClass(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
const downloadURL = "https://open.feishu.cn/presigned/download"
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", downloadURL))
|
||||
|
||||
platform := minutesRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
external := minutesRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
payload := []byte("media")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"video/mp4"}},
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
ContentLength: int64(len(payload)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
|
||||
}, nil
|
||||
}
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "out.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MinutesDownload error = %v, want external route", err)
|
||||
}
|
||||
data, err := os.ReadFile("out.mp4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != "media" {
|
||||
t.Fatalf("downloaded content = %q, want external payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_EmptyDispositionFilename(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
@@ -742,7 +796,7 @@ func TestDownload_TypedErr_NetworkTransport_HttpError(t *testing.T) {
|
||||
Command: "+probe-dl",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
client, err := rctx.Factory.HttpClient()
|
||||
client, err := rctx.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -845,7 +899,7 @@ func TestDownload_TypedErr_OverwriteProtection(t *testing.T) {
|
||||
Command: "+probe-overwrite",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
client, err := rctx.Factory.HttpClient()
|
||||
client, err := rctx.Factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -111,16 +110,6 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
|
||||
}
|
||||
}
|
||||
|
||||
// slideReplaceAPIPath builds the xml_presentation.slide.replace endpoint for a
|
||||
// presentation. Shared by +replace-slide (element-level parts) and
|
||||
// +update-slide (a single whole-page part) so the two cannot drift apart.
|
||||
func slideReplaceAPIPath(presentationID string) string {
|
||||
return fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
}
|
||||
|
||||
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
|
||||
// The "@" prefix is the magic marker for "this is a local file path; upload it and
|
||||
// replace with file_token".
|
||||
|
||||
@@ -18,102 +18,50 @@ var presentationFlagAliases = []string{
|
||||
"url",
|
||||
}
|
||||
|
||||
// contentFlagAliases are the spellings agents reach for instead of --content
|
||||
// when handing a whole page of XML to +update-slide.
|
||||
//
|
||||
// Deliberately not "slide": several slides commands take a --slide-id, so
|
||||
// `--slide <id>` is a likely typo for that, and resolving it to --content
|
||||
// would turn the typo into a request carrying an id where page XML belongs.
|
||||
var contentFlagAliases = []string{
|
||||
"xml",
|
||||
"slide-xml",
|
||||
"slide-content",
|
||||
"content-xml",
|
||||
}
|
||||
|
||||
// presentationAliasMap resolves every --presentation spelling and is attached
|
||||
// to every shortcut that declares that flag.
|
||||
var presentationAliasMap = aliasMap(map[string][]string{"presentation": presentationFlagAliases})
|
||||
|
||||
// wholePageAliasMap additionally resolves the --content spellings. It is
|
||||
// attached only to the whole-page overwrite commands: --content exists on
|
||||
// other slides shortcuts too, and letting these aliases resolve there would
|
||||
// rewrite a mistyped flag into one the caller never meant to use.
|
||||
var wholePageAliasMap = aliasMap(map[string][]string{
|
||||
"presentation": presentationFlagAliases,
|
||||
"content": contentFlagAliases,
|
||||
})
|
||||
|
||||
// aliasMap inverts canonical→aliases into alias→canonical.
|
||||
func aliasMap(byCanonical map[string][]string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for canonical, aliases := range byCanonical {
|
||||
for _, alias := range aliases {
|
||||
out[alias] = canonical
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
all := []struct {
|
||||
shortcut common.Shortcut
|
||||
aliases map[string]string
|
||||
}{
|
||||
{shortcut: SlidesCreate, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesMediaUpload, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesReplaceSlide, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesReplacePages, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesUpdateSlide, aliases: wholePageAliasMap},
|
||||
{shortcut: SlidesUpdate, aliases: wholePageAliasMap},
|
||||
{shortcut: SlidesScreenshot, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesXMLGet, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesHistoryList, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesHistoryRevert, aliases: presentationAliasMap},
|
||||
{shortcut: SlidesHistoryRevertStatus, aliases: presentationAliasMap},
|
||||
all := []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
SlidesHistoryList,
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
out := make([]common.Shortcut, 0, len(all))
|
||||
for _, entry := range all {
|
||||
if hasAliasableFlag(entry.shortcut.Flags, entry.aliases) {
|
||||
entry.shortcut.PostMount = withFlagAliases(entry.aliases, entry.shortcut.PostMount)
|
||||
for i := range all {
|
||||
if hasPresentationFlag(all[i].Flags) {
|
||||
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
|
||||
}
|
||||
out = append(out, entry.shortcut)
|
||||
}
|
||||
return out
|
||||
return all
|
||||
}
|
||||
|
||||
// hasAliasableFlag reports whether the shortcut declares a flag that one of
|
||||
// the aliases resolves to, i.e. whether attaching the normalizer can do
|
||||
// anything.
|
||||
func hasAliasableFlag(flags []common.Flag, aliases map[string]string) bool {
|
||||
func hasPresentationFlag(flags []common.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
for _, canonical := range aliases {
|
||||
if flag.Name == canonical {
|
||||
return true
|
||||
}
|
||||
if flag.Name == "presentation" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withFlagAliases accepts common agent-generated spellings for canonical flags
|
||||
// without registering extra flags. The aliases therefore stay out of help and
|
||||
// completion while resolving to the canonical flag at parse time, matching the
|
||||
// zero-round-trip compatibility used by Sheets.
|
||||
func withFlagAliases(aliases map[string]string, prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
// withPresentationFlagAliases accepts common agent-generated spellings for
|
||||
// --presentation without registering extra flags. The aliases therefore stay
|
||||
// out of help and completion while resolving to the canonical flag at parse
|
||||
// time, matching the zero-round-trip compatibility used by Sheets.
|
||||
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
// fs.Lookup re-enters this func with the canonical name; that
|
||||
// terminates because no canonical name is itself an alias key
|
||||
// (asserted by TestFlagAliasesAreNotCanonicalNames). Looking the
|
||||
// canonical name up keeps a mistyped alias reported as the flag
|
||||
// the caller actually typed on commands that lack the target.
|
||||
if canonical, ok := aliases[name]; ok && fs.Lookup(canonical) != nil {
|
||||
return pflag.NormalizedName(canonical)
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
if name == alias {
|
||||
return pflag.NormalizedName("presentation")
|
||||
}
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
|
||||
@@ -7,128 +7,37 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWithFlagAliases(t *testing.T) {
|
||||
cases := []struct {
|
||||
canonical string
|
||||
aliases []string
|
||||
}{
|
||||
{canonical: "presentation", aliases: presentationFlagAliases},
|
||||
{canonical: "content", aliases: contentFlagAliases},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
for _, alias := range tc.aliases {
|
||||
t.Run(tc.canonical+"/"+alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(tc.canonical, "", tc.canonical+" value")
|
||||
withFlagAliases(wholePageAliasMap, nil)(cmd)
|
||||
func TestWithPresentationFlagAliases(t *testing.T) {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withPresentationFlagAliases(nil)(cmd)
|
||||
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "valABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --%s: %v", alias, tc.canonical, err)
|
||||
}
|
||||
got, err := cmd.Flags().GetString(tc.canonical)
|
||||
if err != nil {
|
||||
t.Fatalf("read --%s: %v", tc.canonical, err)
|
||||
}
|
||||
if got != "valABC" {
|
||||
t.Fatalf("--%s set --%s to %q, want valABC", alias, tc.canonical, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagAliasesOnlyResolveDeclaredFlags pins the guard that keeps a mistyped
|
||||
// alias reported as the flag the caller actually typed: when the command does
|
||||
// not declare the canonical target, the alias must be left alone.
|
||||
func TestFlagAliasesOnlyResolveDeclaredFlags(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withFlagAliases(wholePageAliasMap, nil)(cmd)
|
||||
|
||||
err := cmd.Flags().Parse([]string{"--xml", "<slide/>"})
|
||||
if err == nil {
|
||||
t.Fatal("--xml resolved on a command without --content, want unknown-flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "xml") {
|
||||
t.Fatalf("error should name the flag the user typed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentAliasesStayOffOtherShortcuts is the regression guard for a
|
||||
// package-wide alias table: --content exists on other slides shortcuts, so a
|
||||
// shared table silently turned `--xml` / `--slide-xml` there into a --content
|
||||
// value the caller never meant to pass. Only the whole-page commands may
|
||||
// resolve them.
|
||||
func TestContentAliasesStayOffOtherShortcuts(t *testing.T) {
|
||||
wholePage := map[string]bool{"+update-slide": true, "+update": true}
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if wholePage[shortcut.Command] || !declaresFlag(shortcut.Flags, "content") {
|
||||
continue
|
||||
}
|
||||
if shortcut.PostMount == nil {
|
||||
continue
|
||||
}
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
cmd.Flags().String("content", "", "content")
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
shortcut.PostMount(cmd)
|
||||
|
||||
for _, alias := range contentFlagAliases {
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "x"}); err == nil {
|
||||
t.Errorf("%s resolved --%s to --content; content aliases must be scoped to the whole-page commands", shortcut.Command, alias)
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
|
||||
}
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Fatalf("read --presentation: %v", err)
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagAliasesAreNotCanonicalNames guards the termination argument in
|
||||
// withFlagAliases: fs.Lookup re-enters the normalizer with the canonical name,
|
||||
// which must not itself be an alias key.
|
||||
func TestFlagAliasesAreNotCanonicalNames(t *testing.T) {
|
||||
for _, aliases := range []map[string]string{presentationAliasMap, wholePageAliasMap} {
|
||||
canonical := map[string]bool{}
|
||||
for _, name := range aliases {
|
||||
canonical[name] = true
|
||||
}
|
||||
for alias, target := range aliases {
|
||||
if canonical[alias] {
|
||||
t.Errorf("alias %q is also a canonical flag name; normalization would recurse", alias)
|
||||
}
|
||||
if alias == target {
|
||||
t.Errorf("alias %q maps to itself", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagAliasesDoNotShadowRealFlags catches the dangerous direction of
|
||||
// pflag.SetNormalizeFunc: it re-normalizes flags that are already registered,
|
||||
// so if a shortcut ever declares a flag whose name is an alias key, that flag
|
||||
// collapses into the canonical one — no panic, no error, just a missing flag.
|
||||
func TestFlagAliasesDoNotShadowRealFlags(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.PostMount == nil {
|
||||
continue
|
||||
}
|
||||
for _, flag := range shortcut.Flags {
|
||||
if canonical, ok := wholePageAliasMap[flag.Name]; ok {
|
||||
t.Errorf("%s declares --%s, which is an alias of --%s; the normalizer would erase it", shortcut.Command, flag.Name, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsAttachFlagAliases(t *testing.T) {
|
||||
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
|
||||
count := 0
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !declaresFlag(shortcut.Flags, "presentation") {
|
||||
if !hasPresentationFlag(shortcut.Flags) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
@@ -157,12 +66,3 @@ func TestShortcutsAttachFlagAliases(t *testing.T) {
|
||||
t.Fatal("expected at least one slides shortcut with --presentation")
|
||||
}
|
||||
}
|
||||
|
||||
func declaresFlag(flags []common.Flag, name string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -115,7 +116,10 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
} else {
|
||||
dry.Desc(fmt.Sprintf("Replace %d part(s) on slide %s", len(parts), slideID))
|
||||
}
|
||||
dry.POST(slideReplaceAPIPath(presentationID)).
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(query).
|
||||
Body(body)
|
||||
return dry.Set("parts_count", len(parts))
|
||||
@@ -152,7 +156,11 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
}
|
||||
body := map[string]interface{}{"parts": injected}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", slideReplaceAPIPath(presentationID), query, body)
|
||||
url := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
data, err := runtime.CallAPITyped("POST", url, query, body)
|
||||
if err != nil {
|
||||
return enrichSlidesReplaceError(err)
|
||||
}
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesUpdateSlide applies a whole page of XML to an existing slide, keeping
|
||||
// its slide_id and its position in the deck.
|
||||
//
|
||||
// The caller hands over the page they want; the CLI reads the page that is
|
||||
// there, diffs the two, and sends one element-level part per difference. That
|
||||
// indirection is not a stylistic choice — a single part covering the whole page
|
||||
// is impossible. ReplacePart.block_id is validated as a short ELEMENT id (it
|
||||
// must start with "b"), so the page's own id ("p"-prefixed) and the background
|
||||
// fill's id ("f"-prefixed) are both rejected with 3350001. Element ids are the
|
||||
// only handles this endpoint offers.
|
||||
//
|
||||
// What that buys the caller is the part they actually found painful: they no
|
||||
// longer enumerate parts or hand-write each element's full XML (coordinates,
|
||||
// size, font size included) to restyle a page. What it costs is one capability
|
||||
// the endpoint cannot express at all:
|
||||
//
|
||||
// - The page background lives in <style>, which has no id of its own and
|
||||
// whose <fill> id starts with "f". A changed <style> is therefore an error,
|
||||
// not a silent no-op.
|
||||
//
|
||||
// Two more limits fall out of having no move operation and no way to invent
|
||||
// ids: reordering existing elements is rejected, and an id in --content that
|
||||
// does not exist on the page is rejected rather than created.
|
||||
var SlidesUpdateSlide = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+update-slide",
|
||||
Description: "Apply a full <slide> XML to an existing slide by diffing it against the current page (keeps slide_id and page order; background changes are not supported)",
|
||||
Risk: "write",
|
||||
// slides:presentation:read is unconditional: every execution reads the
|
||||
// page before writing it, so it belongs in the enforced pre-flight set —
|
||||
// ConditionalScopes is metadata only and would let a write-only token
|
||||
// reach the GET before failing.
|
||||
Scopes: []string{"slides:presentation:read", "slides:presentation:update", "slides:presentation:write_only"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Tips: []string{
|
||||
"Read-modify-write: `slides +xml-get --presentation <id> --slide-id <sid> --output page.xml` → edit page.xml → `slides +update-slide --content @page.xml`",
|
||||
"--content is the page's target state: an element you drop is deleted, an element without an id is created",
|
||||
"Keep the <style> block from the read unchanged — the page background cannot be changed through this command",
|
||||
"Elements cannot be reordered and an unknown id cannot be created; both are rejected up front",
|
||||
"Editing one shape / image is cheaper with `slides +replace-slide`",
|
||||
},
|
||||
Flags: updateSlideFlags,
|
||||
Validate: updateSlideValidate,
|
||||
DryRun: updateSlideDryRun,
|
||||
Execute: updateSlideExecute,
|
||||
}
|
||||
|
||||
// SlidesUpdate registers `slides +update` as a hidden alias of +update-slide.
|
||||
//
|
||||
// Agents reach for "slide update" before reading --help (the command did not
|
||||
// exist, so they burned turns on the error plus a help dump). Accepting the
|
||||
// shorter spelling costs nothing and removes those round trips; it stays out
|
||||
// of --help so the canonical name is the only one advertised.
|
||||
//
|
||||
// Derived from the canonical shortcut rather than re-declared, so scopes,
|
||||
// identities and flags cannot drift between the two spellings.
|
||||
var SlidesUpdate = func() common.Shortcut {
|
||||
sc := SlidesUpdateSlide
|
||||
sc.Command = "+update"
|
||||
sc.Hidden = true
|
||||
return sc
|
||||
}()
|
||||
|
||||
var updateSlideFlags = []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "slide-id", Desc: "slide page identifier (slide_id) of the page to update", Required: true},
|
||||
{Name: "content", Desc: "full page XML with a single <slide> root; it is diffed against the current page", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "revision to read and apply against; -1 (default) means latest. Pinning an older revision rebuilds the page from that snapshot and discards newer edits to it"},
|
||||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||||
}
|
||||
|
||||
func updateSlideValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
slideID, err := updateSlideID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only the shape of --content can be checked without a network call; the
|
||||
// diff itself needs the current page.
|
||||
_, err = parseWantedPageFor(runtime.Str("content"), slideID)
|
||||
return err
|
||||
}
|
||||
|
||||
// updateSlideDryRun reports what would be read and how, without calling the
|
||||
// API. The parts cannot be shown: they are derived from the page's current
|
||||
// state, which is exactly what dry-run must not fetch.
|
||||
func updateSlideDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideID, err := updateSlideID(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
wanted, err := parseWantedPageFor(runtime.Str("content"), slideID)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
presentationID := ref.Token
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("3-step orchestration: resolve wiki → read page → replace changed elements").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc(fmt.Sprintf("2-step orchestration: read slide %s, then replace the elements that differ", slideID))
|
||||
}
|
||||
dry.GET(slideReadAPIPath(presentationID)).
|
||||
Desc("[1] Read the current page to diff against").
|
||||
Params(updateSlideQuery(runtime, slideID))
|
||||
dry.POST(slideReplaceAPIPath(presentationID)).
|
||||
Desc("[2] One element-level part per difference; the parts depend on the page's current state").
|
||||
Params(updateSlideQuery(runtime, slideID))
|
||||
return dry.Set("wanted_element_count", len(wanted.Elements))
|
||||
}
|
||||
|
||||
func updateSlideExecute(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slideID, err := updateSlideID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wanted, err := parseWantedPageFor(runtime.Str("content"), slideID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := readCurrentPage(runtime, presentationID, slideID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// diffPage already reports the edits it cannot express as typed validation
|
||||
// errors against --content.
|
||||
diff, err := diffPage(current, wanted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"slide_id": slideID,
|
||||
"parts_count": len(diff.Parts),
|
||||
"replaced": diff.Replaced,
|
||||
"inserted": diff.Inserted,
|
||||
"deleted": diff.Deleted,
|
||||
}
|
||||
if diff.NoteCleared {
|
||||
result["note_cleared"] = true
|
||||
}
|
||||
if diff.NoteReplaced {
|
||||
result["note_replaced"] = true
|
||||
}
|
||||
// Nothing differs: report it instead of sending an empty batch, which the
|
||||
// backend rejects, and instead of claiming a write that never happened.
|
||||
if len(diff.Parts) == 0 {
|
||||
result["unchanged"] = true
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
if len(diff.Parts) > maxReplaceParts {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"the page differs in %d elements, which needs %d parts and exceeds the maximum of %d; split the edit across several calls",
|
||||
len(diff.Parts), len(diff.Parts), maxReplaceParts,
|
||||
).WithParam("--content")
|
||||
}
|
||||
|
||||
parts := make([]map[string]interface{}, 0, len(diff.Parts))
|
||||
for _, part := range diff.Parts {
|
||||
parts = append(parts, part.toMap())
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideReplaceAPIPath(presentationID),
|
||||
updateSlideQuery(runtime, slideID),
|
||||
map[string]interface{}{"parts": parts},
|
||||
)
|
||||
if err != nil {
|
||||
return enrichSlidesReplaceError(err)
|
||||
}
|
||||
|
||||
// A failure reason means the batch was rejected and nothing was written, so
|
||||
// it cannot be reported inside a success envelope. The backend currently
|
||||
// pairs one with a non-zero code, which CallAPITyped already turns into an
|
||||
// error, so this guards an inconsistent response rather than a reachable
|
||||
// path.
|
||||
if reason := strings.TrimSpace(common.GetString(data, "failed_reason")); reason != "" {
|
||||
return errs.NewAPIError(
|
||||
errs.SubtypeInvalidParameters,
|
||||
"slide %s was not updated: %s", slideID, reason,
|
||||
).WithHint(slides3350001Hint)
|
||||
}
|
||||
if _, ok := data["revision_id"]; ok {
|
||||
result["revision_id"] = int(common.GetFloat(data, "revision_id"))
|
||||
}
|
||||
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readCurrentPage fetches the page the diff is computed against.
|
||||
func readCurrentPage(runtime *common.RuntimeContext, presentationID, slideID string) (pageDoc, error) {
|
||||
data, err := runtime.CallAPITyped("GET", slideReadAPIPath(presentationID), updateSlideQuery(runtime, slideID), nil)
|
||||
if err != nil {
|
||||
return pageDoc{}, err
|
||||
}
|
||||
content := common.GetString(common.GetMap(data, "slide"), "content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return pageDoc{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "reading slide %s returned empty content", slideID)
|
||||
}
|
||||
current, err := parsePageDoc(content)
|
||||
if err != nil {
|
||||
return pageDoc{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "slide %s returned XML the CLI cannot parse: %v", slideID, err).WithCause(err)
|
||||
}
|
||||
// A page whose structure the diff cannot see cannot be safely edited: the
|
||||
// unrecognized part would be invisible to the comparison, so the command
|
||||
// could neither preserve it deliberately nor notice the caller changing it.
|
||||
if current.Unsupported != "" {
|
||||
return pageDoc{}, errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"slide %s contains %s, which this command cannot represent; use `slides +replace-slide` for element-level edits on this page",
|
||||
slideID, current.Unsupported,
|
||||
)
|
||||
}
|
||||
// A page carrying an <undefined> placeholder is refused outright. The
|
||||
// placeholder stands for an object the server could not export (a
|
||||
// whiteboard, unexported media); whether the whole-page rewrite behind
|
||||
// slide.replace preserves an untouched one is a server-owned behavior that
|
||||
// no self-contained test can pin down — boards cannot be created
|
||||
// programmatically. Editing on top of an unverifiable assumption risks
|
||||
// silently destroying the one object the caller cannot see, so the page is
|
||||
// off-limits to this command until preservation is provable.
|
||||
for _, el := range current.Elements {
|
||||
if el.Tag == placeholderTag {
|
||||
return pageDoc{}, errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"slide %s contains an <undefined> placeholder (element %s) for an object the server could not export, such as a whiteboard; this command refuses to edit the page because it cannot prove a rewrite would preserve that object — use `slides +replace-slide` for element-level edits here",
|
||||
slideID, el.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// parseWantedPage validates --content and decomposes it.
|
||||
//
|
||||
// The root-tag check is a safety gate, not politeness: --content describes the
|
||||
// whole page, so an element-level fragment would be read as "the page should
|
||||
// contain only this", and every other element on it would be deleted.
|
||||
func parseWantedPage(content string) (pageDoc, error) {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
return pageDoc{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content")
|
||||
}
|
||||
doc, err := parsePageDoc(trimmed)
|
||||
if err != nil {
|
||||
return pageDoc{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not well-formed XML: %v", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if doc.RootTag == "" {
|
||||
return pageDoc{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content has no root element; pass one full <slide>…</slide> fragment").WithParam("--content")
|
||||
}
|
||||
if doc.RootTag != "slide" {
|
||||
return pageDoc{}, updateSlideRootTagError(doc.RootTag)
|
||||
}
|
||||
if doc.TrailingTag != "" {
|
||||
return pageDoc{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--content has a <%s> element after the </slide> root; pass exactly one page per call",
|
||||
doc.TrailingTag,
|
||||
).WithParam("--content")
|
||||
}
|
||||
if doc.TrailingText {
|
||||
return pageDoc{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--content has text after the </slide> root; pass exactly one <slide>…</slide> fragment",
|
||||
).WithParam("--content")
|
||||
}
|
||||
// Anything the decomposition could not place has no part it could become.
|
||||
// Accepting it and diffing only the recognized parts would drop the edit —
|
||||
// and could even answer `unchanged` for a change the caller asked for.
|
||||
if doc.Unsupported != "" {
|
||||
return pageDoc{}, contentError(
|
||||
"--content contains %s, which this command cannot represent; a <slide> carries exactly one <style>, one <data> and one <note>",
|
||||
doc.Unsupported,
|
||||
)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// parseWantedPageFor additionally pins the root id, when present, to the page
|
||||
// being updated. XML fetched for page A and posted against --slide-id for page
|
||||
// B is the classic wrong-target mistake; the element-id checks catch it only
|
||||
// incidentally (an empty page, or one whose element ids were stripped, would
|
||||
// sail through and rebuild B with A's content).
|
||||
func parseWantedPageFor(content, slideID string) (pageDoc, error) {
|
||||
doc, err := parseWantedPage(content)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
if doc.RootID != "" && doc.RootID != slideID {
|
||||
return doc, contentError(
|
||||
"--content root carries id %q but --slide-id is %q; this XML looks like it was read from a different page — re-run `slides +xml-get` for this page, or drop the root id to apply the content here",
|
||||
doc.RootID, slideID,
|
||||
)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// updateSlideRootTagError explains what to use instead, picked by what the
|
||||
// caller actually passed: a whole presentation is a multi-page job, anything
|
||||
// else is an element-level edit.
|
||||
func updateSlideRootTagError(rootTag string) error {
|
||||
remedy := "use `slides +replace-slide` to edit individual elements"
|
||||
if rootTag == "presentation" {
|
||||
remedy = "pass a single page's <slide> XML, or use `slides +replace-pages` to rebuild several pages at once"
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--content root element is <%s>, but +update-slide takes a whole page and requires a single <slide> root; %s",
|
||||
rootTag, remedy,
|
||||
).WithParam("--content")
|
||||
}
|
||||
|
||||
// updateSlideID reads and validates --slide-id.
|
||||
func updateSlideID(runtime *common.RuntimeContext) (string, error) {
|
||||
slideID := strings.TrimSpace(runtime.Str("slide-id"))
|
||||
if slideID == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
|
||||
}
|
||||
return slideID, nil
|
||||
}
|
||||
|
||||
// updateSlideQuery builds the query params shared by the read, the write and
|
||||
// dry-run. The same revision is used for both calls so the parts are applied to
|
||||
// the snapshot they were computed from.
|
||||
func updateSlideQuery(runtime *common.RuntimeContext, slideID string) map[string]interface{} {
|
||||
query := map[string]interface{}{
|
||||
"slide_id": slideID,
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if tid := strings.TrimSpace(runtime.Str("tid")); tid != "" {
|
||||
query["tid"] = tid
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// slideReadAPIPath is the single-slide read endpoint.
|
||||
func slideReadAPIPath(presentationID string) string {
|
||||
return fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// This file turns "here is the page I want" into the element-level parts the
|
||||
// slide.replace endpoint accepts.
|
||||
//
|
||||
// A whole-page part is not an option: ReplacePart.block_id is validated as a
|
||||
// short ELEMENT id (it must start with "b"), so neither the page's own id nor
|
||||
// the background fill's id (which starts with "f") can be addressed. Verified
|
||||
// against the live API — a slide-rooted replacement and a fill-targeted
|
||||
// replacement are both rejected with 3350001, while element-level parts on the
|
||||
// same page succeed. So the CLI diffs the caller's page against the current one
|
||||
// and emits one part per changed element.
|
||||
//
|
||||
// Comparison is canonical (attributes sorted, insignificant whitespace
|
||||
// dropped) because the server returns pretty-printed XML with normalized
|
||||
// attribute order and injected style defaults; a raw string compare would call
|
||||
// every element changed. What gets SENT is the caller's exact bytes, so their
|
||||
// formatting and attribute order survive into the page.
|
||||
|
||||
// contentError reports an edit --content asks for that element-level parts
|
||||
// cannot express. Every one of these is a refusal to guess: the alternative is
|
||||
// applying part of the edit, or returning success with it silently dropped.
|
||||
func contentError(format string, args ...any) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).WithParam("--content")
|
||||
}
|
||||
|
||||
// smlNamespaces are the namespace forms a page may declare on its root — the
|
||||
// official identifier plus the two read-back spellings the server emits.
|
||||
// Mirrors ACCEPTED_SML_NAMESPACES in
|
||||
// skills/lark-slides/scripts/sxsd_validator.py; keep the two lists in sync.
|
||||
var smlNamespaces = map[string]bool{
|
||||
"http://www.larkoffice.com/sml/2.0": true,
|
||||
"https://www.larkoffice.com/sml/2.0": true,
|
||||
"/sml/2.0": true,
|
||||
}
|
||||
|
||||
// placeholderTag is the element the server substitutes for objects it cannot
|
||||
// export as SML — a whiteboard read without its export option, video and audio
|
||||
// embeds. The caller cannot see what is behind one, and no self-contained
|
||||
// endpoint test can prove that a page rewrite preserves it (boards cannot be
|
||||
// created programmatically — the CLI has no whiteboard-create and SML has no
|
||||
// whiteboard element), so pages carrying one are refused outright rather than
|
||||
// edited on an unverifiable assumption. The element-level escape hatch is
|
||||
// +replace-slide.
|
||||
const placeholderTag = "undefined"
|
||||
|
||||
// pageNode is one addressable node of a page.
|
||||
type pageNode struct {
|
||||
Tag string
|
||||
// ID is the short id carried by the node, empty when it has none. Only
|
||||
// "b"-prefixed ids are addressable as a part's block_id.
|
||||
ID string
|
||||
// Raw is the caller's (or server's) exact bytes for this node.
|
||||
Raw string
|
||||
// Canon is the comparison form: attributes sorted, whitespace collapsed.
|
||||
Canon string
|
||||
}
|
||||
|
||||
// pageDoc is a decomposed <slide> document.
|
||||
type pageDoc struct {
|
||||
// RootTag is the local name of the first element, "" when there is none.
|
||||
RootTag string
|
||||
RootID string
|
||||
// Style and Note are the <style> / <note> children of <slide>, nil when
|
||||
// absent.
|
||||
Style *pageNode
|
||||
Note *pageNode
|
||||
// Elements are the <data> children in document order.
|
||||
Elements []pageNode
|
||||
// TrailingTag and TrailingText report content after the root's close tag.
|
||||
TrailingTag string
|
||||
TrailingText bool
|
||||
// Unsupported names the first slide-level structure the diff cannot
|
||||
// represent: an unknown direct child of <slide>, a duplicate <style> /
|
||||
// <data> / <note>, or stray text. Empty means the page decomposed cleanly.
|
||||
//
|
||||
// This must be an error, not a shrug: a diff computed from only the
|
||||
// recognized parts would drop the unrecognized edit and could even report
|
||||
// `unchanged` — a success claim for a change that never happened.
|
||||
Unsupported string
|
||||
}
|
||||
|
||||
// elementByID indexes Elements by id, skipping nodes without one.
|
||||
func (d pageDoc) elementByID() map[string]pageNode {
|
||||
out := make(map[string]pageNode, len(d.Elements))
|
||||
for _, el := range d.Elements {
|
||||
if el.ID != "" {
|
||||
out[el.ID] = el
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orderedIDs returns the ids of Elements that carry one, in document order.
|
||||
func (d pageDoc) orderedIDs() []string {
|
||||
out := make([]string, 0, len(d.Elements))
|
||||
for _, el := range d.Elements {
|
||||
if el.ID != "" {
|
||||
out = append(out, el.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parsePageDoc walks a <slide> document once and records every addressable
|
||||
// node together with the exact bytes it came from.
|
||||
//
|
||||
// Raw slices are cut from the input using the decoder's byte offsets rather
|
||||
// than re-serialized, so a replacement carries the caller's own formatting
|
||||
// instead of whatever encoding/xml would emit.
|
||||
func parsePageDoc(pageXML string) (pageDoc, error) {
|
||||
var doc pageDoc
|
||||
decoder := xml.NewDecoder(strings.NewReader(pageXML))
|
||||
var (
|
||||
stack []string
|
||||
rootClosed bool
|
||||
dataSeen int
|
||||
// capture is the node currently being sliced out: its start offset and
|
||||
// the depth at which it ends.
|
||||
capturing bool
|
||||
captureAt int64
|
||||
captureTag string
|
||||
captureID string
|
||||
captureIn string // "root" for <slide> children, "data" for <data> children
|
||||
)
|
||||
unsupported := func(format string, args ...any) {
|
||||
if doc.Unsupported == "" {
|
||||
doc.Unsupported = fmt.Sprintf(format, args...)
|
||||
}
|
||||
}
|
||||
for {
|
||||
before := decoder.InputOffset()
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
switch {
|
||||
case len(stack) == 0 && rootClosed:
|
||||
if doc.TrailingTag == "" {
|
||||
doc.TrailingTag = t.Name.Local
|
||||
}
|
||||
case len(stack) == 0:
|
||||
doc.RootTag, doc.RootID = t.Name.Local, attrValue(t, "id")
|
||||
// The diff carries the root's id and nothing else, so any
|
||||
// other attribute would be accepted and then neither compared
|
||||
// nor sent — an edit to it would vanish into `unchanged`.
|
||||
// Namespace declarations are no exception: a binding inherited
|
||||
// from the root changes what every descendant name means, and
|
||||
// the canonical comparison reads local names precisely because
|
||||
// legitimate pages differ only in carrying the one official
|
||||
// declaration or not. Anything else must not be waved through.
|
||||
for _, attr := range t.Attr {
|
||||
switch {
|
||||
case attr.Name.Space == "" && attr.Name.Local == "id":
|
||||
case attr.Name.Space == "" && attr.Name.Local == "xmlns":
|
||||
if !smlNamespaces[attr.Value] {
|
||||
unsupported("an unsupported xmlns %q on <%s>", attr.Value, t.Name.Local)
|
||||
}
|
||||
case attr.Name.Space == "xmlns":
|
||||
unsupported("a prefixed namespace declaration %q on <%s>", "xmlns:"+attr.Name.Local, t.Name.Local)
|
||||
default:
|
||||
unsupported("an unsupported attribute %q on <%s>", attrDisplayName(attr), t.Name.Local)
|
||||
}
|
||||
}
|
||||
case !capturing && len(stack) == 1:
|
||||
// Direct children of <slide>: exactly one <style>, one <data>
|
||||
// and one <note> are representable; anything else has no
|
||||
// element-level part it could become.
|
||||
switch t.Name.Local {
|
||||
case "style":
|
||||
if doc.Style != nil {
|
||||
unsupported("a second <style> element")
|
||||
break
|
||||
}
|
||||
capturing, captureAt = true, elementStart(pageXML, before)
|
||||
captureTag, captureID, captureIn = t.Name.Local, attrValue(t, "id"), "root"
|
||||
case "note":
|
||||
if doc.Note != nil {
|
||||
unsupported("a second <note> element")
|
||||
break
|
||||
}
|
||||
capturing, captureAt = true, elementStart(pageXML, before)
|
||||
captureTag, captureID, captureIn = t.Name.Local, attrValue(t, "id"), "root"
|
||||
case "data":
|
||||
dataSeen++
|
||||
if dataSeen > 1 {
|
||||
unsupported("a second <data> element")
|
||||
}
|
||||
// <data> is pure structure to the diff; an attribute on it
|
||||
// — namespace declarations included, since captured Raw
|
||||
// slices would not carry an inherited binding — has no
|
||||
// element-level part it could travel in.
|
||||
for _, attr := range t.Attr {
|
||||
unsupported("an unsupported attribute %q on <data>", attrDisplayName(attr))
|
||||
}
|
||||
default:
|
||||
unsupported("an unknown <%s> element directly under <slide>", t.Name.Local)
|
||||
}
|
||||
case !capturing && len(stack) == 2 && stack[1] == "data":
|
||||
capturing, captureAt = true, elementStart(pageXML, before)
|
||||
captureTag, captureID, captureIn = t.Name.Local, attrValue(t, "id"), "data"
|
||||
}
|
||||
stack = append(stack, t.Name.Local)
|
||||
case xml.EndElement:
|
||||
closingDepth := len(stack)
|
||||
if closingDepth > 0 {
|
||||
stack = stack[:closingDepth-1]
|
||||
}
|
||||
if len(stack) == 0 {
|
||||
rootClosed = true
|
||||
}
|
||||
// A captured node ends when the stack returns to the depth it
|
||||
// started at: "root" children start at depth 1, "data" children at
|
||||
// depth 2.
|
||||
startDepth := 1
|
||||
if captureIn == "data" {
|
||||
startDepth = 2
|
||||
}
|
||||
if capturing && len(stack) == startDepth {
|
||||
raw := strings.TrimSpace(pageXML[captureAt:decoder.InputOffset()])
|
||||
canon, err := canonicalizeElement(raw)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
node := pageNode{Tag: captureTag, ID: captureID, Raw: raw, Canon: canon}
|
||||
switch {
|
||||
case captureIn == "root" && captureTag == "style":
|
||||
doc.Style = &node
|
||||
case captureIn == "root" && captureTag == "note":
|
||||
doc.Note = &node
|
||||
case captureIn == "data":
|
||||
doc.Elements = append(doc.Elements, node)
|
||||
}
|
||||
capturing = false
|
||||
}
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(t)) == "" {
|
||||
break // pretty-printed indentation, never meaningful
|
||||
}
|
||||
switch {
|
||||
case rootClosed && len(stack) == 0:
|
||||
doc.TrailingText = true
|
||||
case !capturing && len(stack) == 1:
|
||||
unsupported("text directly inside <slide>")
|
||||
case !capturing && len(stack) == 2 && stack[1] == "data":
|
||||
unsupported("text directly inside <data>")
|
||||
}
|
||||
}
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// elementStart returns the offset of the '<' that opens the token beginning at
|
||||
// or after off, so a captured slice starts at the tag rather than at the
|
||||
// whitespace preceding it.
|
||||
func elementStart(s string, off int64) int64 {
|
||||
for i := int(off); i < len(s); i++ {
|
||||
if s[i] == '<' {
|
||||
return int64(i)
|
||||
}
|
||||
}
|
||||
return off
|
||||
}
|
||||
|
||||
func attrValue(el xml.StartElement, name string) string {
|
||||
for _, attr := range el.Attr {
|
||||
if attr.Name.Local == name {
|
||||
return attr.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// attrDisplayName renders an attribute name for error messages.
|
||||
func attrDisplayName(attr xml.Attr) string {
|
||||
if attr.Name.Space != "" {
|
||||
return attr.Name.Space + ":" + attr.Name.Local
|
||||
}
|
||||
return attr.Name.Local
|
||||
}
|
||||
|
||||
// canonicalizeElement renders an element fragment in a stable form: attributes
|
||||
// sorted by name, indentation between structural elements dropped, paragraph
|
||||
// text preserved verbatim and escaped.
|
||||
//
|
||||
// This exists so "unchanged" survives the round trip through the server, which
|
||||
// re-orders attributes, indents the XML and injects style defaults that the
|
||||
// caller never wrote.
|
||||
//
|
||||
// The whitespace rule is asymmetric by design. Outside <p>, whitespace-only
|
||||
// character data is the pretty-printer's indentation and never content. Inside
|
||||
// a <p> subtree it is kept verbatim: SML itself collapses a literal space
|
||||
// between inline tags, but a preserved space written as   decodes to the
|
||||
// very same token, so dropping "insignificant" whitespace here would also drop
|
||||
// a real   edit as `unchanged`. Keeping both costs at most a spurious
|
||||
// rewrite of identical content; dropping either loses an edit.
|
||||
func canonicalizeElement(fragment string) (string, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(fragment))
|
||||
var out strings.Builder
|
||||
pDepth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return out.String(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
// Element names stay namespace-free on purpose: a fragment cut
|
||||
// from a document with a default xmlns resolves its elements into
|
||||
// that namespace, while the same fragment from the server's plain
|
||||
// output does not — including it would make every round-trip look
|
||||
// changed. SML is a single vocabulary, so local names cannot clash.
|
||||
out.WriteString("<" + t.Name.Local)
|
||||
attrs := make([]string, 0, len(t.Attr))
|
||||
for _, attr := range t.Attr {
|
||||
name := attr.Name.Local
|
||||
// Attributes do not inherit the default namespace, so a
|
||||
// non-empty Space is explicit (xmlns declarations, prefixed
|
||||
// attributes) and part of the attribute's identity.
|
||||
if attr.Name.Space != "" {
|
||||
name = attr.Name.Space + ":" + name
|
||||
}
|
||||
// Quote the value: with bare name=value concatenation,
|
||||
// alt="foo rotateWithShape=true" and the two attributes
|
||||
// alt="foo" rotateWithShape="true" canonicalize identically,
|
||||
// and the diff would drop a real change as unchanged.
|
||||
attrs = append(attrs, name+"="+strconv.Quote(attr.Value))
|
||||
}
|
||||
sort.Strings(attrs)
|
||||
for _, attr := range attrs {
|
||||
out.WriteString(" " + attr)
|
||||
}
|
||||
out.WriteString(">")
|
||||
if t.Name.Local == "p" {
|
||||
pDepth++
|
||||
}
|
||||
case xml.EndElement:
|
||||
out.WriteString("</" + t.Name.Local + ">")
|
||||
if t.Name.Local == "p" && pDepth > 0 {
|
||||
pDepth--
|
||||
}
|
||||
case xml.CharData:
|
||||
if pDepth == 0 && strings.TrimSpace(string(t)) == "" {
|
||||
break // indentation between structural elements, never content
|
||||
}
|
||||
// Escaped, so text can never imitate markup in the comparison
|
||||
// stream: a paragraph holding the literal text "</p><p>" must not
|
||||
// compare equal to two empty paragraphs.
|
||||
if err := xml.EscapeText(&out, t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replacePart is one entry of the request body.
|
||||
type replacePartOut struct {
|
||||
Action string
|
||||
BlockID string
|
||||
Replacement string
|
||||
Insertion string
|
||||
InsertBeforeBlockID string
|
||||
}
|
||||
|
||||
func (p replacePartOut) toMap() map[string]interface{} {
|
||||
m := map[string]interface{}{"action": p.Action}
|
||||
switch p.Action {
|
||||
case "block_replace":
|
||||
m["block_id"] = p.BlockID
|
||||
m["replacement"] = p.Replacement
|
||||
case "block_insert":
|
||||
m["insertion"] = p.Insertion
|
||||
if p.InsertBeforeBlockID != "" {
|
||||
m["insert_before_block_id"] = p.InsertBeforeBlockID
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pageDiff is the outcome of comparing the wanted page against the current one.
|
||||
type pageDiff struct {
|
||||
Parts []replacePartOut
|
||||
// Counters describe what the parts do, for the result envelope.
|
||||
Replaced, Inserted, Deleted int
|
||||
NoteCleared, NoteReplaced bool
|
||||
}
|
||||
|
||||
// diffPage produces the parts that turn current into wanted.
|
||||
//
|
||||
// Semantics: --content is the page's target state. An element present in
|
||||
// current but absent from wanted is deleted; an element without an id is
|
||||
// created. The background is the one thing that cannot be expressed, so a
|
||||
// changed <style> is an error rather than a silent no-op.
|
||||
func diffPage(current, wanted pageDoc) (pageDiff, error) {
|
||||
var diff pageDiff
|
||||
|
||||
if err := diffStyle(current, wanted); err != nil {
|
||||
return diff, err
|
||||
}
|
||||
|
||||
currentByID := current.elementByID()
|
||||
// Pages carrying a placeholder are rejected on read (see readCurrentPage),
|
||||
// so one here can only be hand-authored content — and there is nothing it
|
||||
// could correctly mean: the object it stands for cannot be created, and a
|
||||
// page that really had one would never have reached the diff.
|
||||
for _, el := range wanted.Elements {
|
||||
if el.Tag == placeholderTag {
|
||||
return diff, contentError(
|
||||
"--content contains an <undefined> element; it is only the server's stand-in for an object it could not export, and pages carrying one cannot be edited by this command — use `slides +replace-slide`",
|
||||
)
|
||||
}
|
||||
}
|
||||
wantedIDs := map[string]bool{}
|
||||
for _, el := range wanted.Elements {
|
||||
if el.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := currentByID[el.ID]; !ok {
|
||||
return diff, contentError(
|
||||
"element id %q in --content does not exist on slide %s; drop the id to create it as a new element, or re-read the page",
|
||||
el.ID, current.RootID,
|
||||
)
|
||||
}
|
||||
if wantedIDs[el.ID] {
|
||||
return diff, contentError("element id %q appears twice in --content", el.ID)
|
||||
}
|
||||
wantedIDs[el.ID] = true
|
||||
}
|
||||
|
||||
// Surviving elements must keep their relative order: there is no move
|
||||
// operation, so a reorder cannot be expressed and must not be silently
|
||||
// dropped.
|
||||
if err := checkOrderPreserved(current.orderedIDs(), wanted.orderedIDs(), wantedIDs); err != nil {
|
||||
return diff, err
|
||||
}
|
||||
|
||||
// Deletions first so later inserts land at the positions the caller meant.
|
||||
for _, el := range current.Elements {
|
||||
if el.ID != "" && !wantedIDs[el.ID] {
|
||||
diff.Parts = append(diff.Parts, replacePartOut{
|
||||
Action: "block_replace",
|
||||
BlockID: el.ID,
|
||||
// An empty replacement deletes the block.
|
||||
Replacement: "",
|
||||
})
|
||||
diff.Deleted++
|
||||
}
|
||||
}
|
||||
|
||||
for i, el := range wanted.Elements {
|
||||
switch {
|
||||
case el.ID == "":
|
||||
diff.Parts = append(diff.Parts, replacePartOut{
|
||||
Action: "block_insert",
|
||||
Insertion: el.Raw,
|
||||
InsertBeforeBlockID: nextSurvivingID(wanted.Elements, i, wantedIDs),
|
||||
})
|
||||
diff.Inserted++
|
||||
case el.Canon != currentByID[el.ID].Canon:
|
||||
diff.Parts = append(diff.Parts, replacePartOut{
|
||||
Action: "block_replace",
|
||||
BlockID: el.ID,
|
||||
Replacement: el.Raw,
|
||||
})
|
||||
diff.Replaced++
|
||||
}
|
||||
}
|
||||
|
||||
notePart, err := diffNote(current, wanted)
|
||||
if err != nil {
|
||||
return diff, err
|
||||
}
|
||||
if notePart != nil {
|
||||
diff.Parts = append(diff.Parts, *notePart)
|
||||
if wanted.Note == nil {
|
||||
diff.NoteCleared = true
|
||||
} else {
|
||||
diff.NoteReplaced = true
|
||||
}
|
||||
}
|
||||
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// diffStyle rejects a background change instead of dropping it.
|
||||
//
|
||||
// <style> has no id of its own and the <fill> inside it carries an "f"-prefixed
|
||||
// id, which the endpoint's block_id validation rejects. There is therefore no
|
||||
// way to change a page's background through this path at all — saying so beats
|
||||
// returning success with the background untouched.
|
||||
func diffStyle(current, wanted pageDoc) error {
|
||||
currentStyle, wantedStyle := "", ""
|
||||
if current.Style != nil {
|
||||
currentStyle = current.Style.Canon
|
||||
}
|
||||
if wanted.Style != nil {
|
||||
wantedStyle = wanted.Style.Canon
|
||||
}
|
||||
if currentStyle == wantedStyle {
|
||||
return nil
|
||||
}
|
||||
if wanted.Style == nil {
|
||||
return contentError(
|
||||
"--content has no <style> but the slide has one; the page background cannot be changed through this command — copy the existing <style> over from `slides +xml-get` output",
|
||||
)
|
||||
}
|
||||
return contentError(
|
||||
"--content changes <style> (the page background), which this command cannot express: the background has no addressable element id — keep the <style> from `slides +xml-get` output unchanged",
|
||||
)
|
||||
}
|
||||
|
||||
// diffNote turns a note change into a part, or reports that it cannot be made.
|
||||
func diffNote(current, wanted pageDoc) (*replacePartOut, error) {
|
||||
currentNote, wantedNote := "", ""
|
||||
if current.Note != nil {
|
||||
currentNote = current.Note.Canon
|
||||
}
|
||||
if wanted.Note != nil {
|
||||
wantedNote = wanted.Note.Canon
|
||||
}
|
||||
if currentNote == wantedNote {
|
||||
return nil, nil
|
||||
}
|
||||
// Editing or clearing the note both go through the existing note block, so
|
||||
// the current page has to have one to address.
|
||||
if current.Note == nil || current.Note.ID == "" {
|
||||
return nil, contentError("slide %s has no addressable <note>; speaker notes cannot be changed through this command", current.RootID)
|
||||
}
|
||||
replacement := fmt.Sprintf("<note id=%q><content/></note>", current.Note.ID)
|
||||
if wanted.Note != nil {
|
||||
replacement = wanted.Note.Raw
|
||||
}
|
||||
return &replacePartOut{
|
||||
Action: "block_replace",
|
||||
BlockID: current.Note.ID,
|
||||
Replacement: replacement,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkOrderPreserved verifies the surviving ids appear in the same relative
|
||||
// order on both sides.
|
||||
func checkOrderPreserved(currentIDs, wantedIDs []string, surviving map[string]bool) error {
|
||||
kept := make([]string, 0, len(currentIDs))
|
||||
for _, id := range currentIDs {
|
||||
if surviving[id] {
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
if len(kept) != len(wantedIDs) {
|
||||
// Length mismatch is already covered by the unknown-id and duplicate
|
||||
// checks in diffPage; nothing to add here.
|
||||
return nil
|
||||
}
|
||||
for i := range kept {
|
||||
if kept[i] != wantedIDs[i] {
|
||||
return contentError(
|
||||
"--content reorders existing elements (expected %s at position %d, got %s); this command cannot move elements — delete and re-insert them, or keep the original order",
|
||||
kept[i], i+1, wantedIDs[i],
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextSurvivingID finds the id-bearing element that follows position i, so a
|
||||
// new element is inserted at the position the caller wrote it in. An empty
|
||||
// result means "append to the end of the page".
|
||||
func nextSurvivingID(elements []pageNode, i int, surviving map[string]bool) string {
|
||||
for _, el := range elements[i+1:] {
|
||||
if el.ID != "" && surviving[el.ID] {
|
||||
return el.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,898 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// currentPageXML is shaped like what the server actually returns: pretty
|
||||
// printed, attributes in its own order, style defaults injected that the caller
|
||||
// never wrote, and ids on the style fill ("f"-prefixed) and the note
|
||||
// ("b"-prefixed).
|
||||
const currentPageXML = `<slide id="piy">
|
||||
<style>
|
||||
<fill id="fiy">
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="800" height="120" topLeftX="80" topLeftY="80" type="text" id="bbD">
|
||||
<content textType="title" fontSize="54" fontFamily="思源黑体">
|
||||
<p>BEFORE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="400" height="80" topLeftX="80" topLeftY="260" type="text" id="bbv">
|
||||
<content fontSize="16" fontFamily="思源黑体">
|
||||
<p>SECOND</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bbb">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>`
|
||||
|
||||
const currentStyleXML = `<style>
|
||||
<fill id="fiy">
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
</style>`
|
||||
|
||||
// wantPage assembles a page in the caller's own style, so the tests exercise
|
||||
// the canonical comparison rather than string equality.
|
||||
func wantPage(style, data, note string) string {
|
||||
return `<slide id="piy">` + style + `<data>` + data + `</data>` + note + `</slide>`
|
||||
}
|
||||
|
||||
const (
|
||||
elemOne = `<shape id="bbD" type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title" fontSize="54" fontFamily="思源黑体"><p>BEFORE</p></content></shape>`
|
||||
elemOneNew = `<shape id="bbD" type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title" fontSize="54" fontFamily="楷体"><p>BEFORE</p></content></shape>`
|
||||
elemTwo = `<shape id="bbv" type="text" topLeftX="80" topLeftY="260" width="400" height="80"><content fontSize="16" fontFamily="思源黑体"><p>SECOND</p></content></shape>`
|
||||
noteKept = `<note id="bbb"><content/></note>`
|
||||
)
|
||||
|
||||
// registerPageRead stubs the read half. The write stub must be registered with
|
||||
// Method POST, since httpmock matches the URL by substring and "/slide" is a
|
||||
// prefix of "/slide/replace".
|
||||
func registerPageRead(t *testing.T, reg *httpmock.Registry, pageXML string) {
|
||||
t.Helper()
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{"slide_id": "piy", "content": pageXML},
|
||||
"revision_id": 7,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerWriteStub(t *testing.T, reg *httpmock.Registry, revision int) *httpmock.Stub {
|
||||
t.Helper()
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/slide/replace",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": revision}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
// forbidWrite registers a POST stub that fails the test if it is ever hit.
|
||||
func forbidWrite(t *testing.T, reg *httpmock.Registry, why string) {
|
||||
t.Helper()
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/slide/replace",
|
||||
Optional: true,
|
||||
OnMatch: func(*http.Request) { t.Error(why) },
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
}
|
||||
|
||||
func runUpdateSlide(t *testing.T, f *cmdutil.Factory, content string, extra ...string) error {
|
||||
t.Helper()
|
||||
args := append([]string{
|
||||
"+update-slide",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "piy",
|
||||
"--content", content,
|
||||
}, extra...)
|
||||
return runSlidesShortcut(t, f, nil, SlidesUpdateSlide, append(args, "--as", "user"))
|
||||
}
|
||||
|
||||
func TestUpdateSlideDeclaredScopes(t *testing.T) {
|
||||
// The read scope is ENFORCED, not merely declared: every execution reads
|
||||
// the page before writing it, and ConditionalScopes would let a write-only
|
||||
// token reach the GET before failing.
|
||||
want := []string{"slides:presentation:read", "slides:presentation:update", "slides:presentation:write_only"}
|
||||
for _, sc := range []common.Shortcut{SlidesUpdateSlide, SlidesUpdate} {
|
||||
if got := sc.ScopesForIdentity("user"); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s user preflight scopes = %#v, want %#v", sc.Command, got, want)
|
||||
}
|
||||
declared := sc.DeclaredScopesForIdentity("user")
|
||||
found := false
|
||||
for _, got := range declared {
|
||||
if got == "wiki:node:read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s declared scopes %#v missing wiki:node:read", sc.Command, declared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideIsRegisteredWithAlias(t *testing.T) {
|
||||
canonical := findSlidesShortcut(t, "+update-slide")
|
||||
alias := findSlidesShortcut(t, "+update")
|
||||
|
||||
if canonical.Hidden {
|
||||
t.Error("+update-slide must be visible in --help")
|
||||
}
|
||||
if !alias.Hidden {
|
||||
t.Error("+update alias must stay hidden so only the canonical name is advertised")
|
||||
}
|
||||
if alias.Service != canonical.Service || alias.Risk != canonical.Risk ||
|
||||
!reflect.DeepEqual(alias.AuthTypes, canonical.AuthTypes) ||
|
||||
!reflect.DeepEqual(alias.Scopes, canonical.Scopes) ||
|
||||
!reflect.DeepEqual(alias.ConditionalScopes, canonical.ConditionalScopes) ||
|
||||
!reflect.DeepEqual(alias.Flags, canonical.Flags) ||
|
||||
!reflect.DeepEqual(alias.Tips, canonical.Tips) ||
|
||||
alias.Description != canonical.Description {
|
||||
t.Error("alias metadata drifted from +update-slide; it must be derived, not re-declared")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideSendsOnePartPerChangedElement is the core contract: only what
|
||||
// differs is touched, the part addresses the element by its own id, and the
|
||||
// replacement carries the caller's bytes rather than a re-serialized form.
|
||||
func TestUpdateSlideSendsOnePartPerChangedElement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 8)
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("parts = %d, want 1 (only the restyled element differs): %#v", len(parts), parts)
|
||||
}
|
||||
if parts[0].Action != "block_replace" || parts[0].BlockID != "bbD" {
|
||||
t.Errorf("part = %+v, want block_replace on bbD", parts[0])
|
||||
}
|
||||
if !strings.Contains(parts[0].Replacement, `fontFamily="楷体"`) {
|
||||
t.Errorf("replacement lost the edit: %q", parts[0].Replacement)
|
||||
}
|
||||
if parts[0].Replacement != elemOneNew {
|
||||
t.Errorf("replacement should be the caller's exact bytes:\n got %q\nwant %q", parts[0].Replacement, elemOneNew)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["parts_count"] != float64(1) || data["replaced"] != float64(1) ||
|
||||
data["inserted"] != float64(0) || data["deleted"] != float64(0) {
|
||||
t.Errorf("counters = %v", data)
|
||||
}
|
||||
if data["revision_id"] != float64(8) {
|
||||
t.Errorf("revision_id = %v, want 8", data["revision_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideCanonicalComparison pins that the server's own formatting does
|
||||
// not read as a change. The caller here reorders attributes, collapses the
|
||||
// indentation and self-closes differently, but means the same page.
|
||||
func TestUpdateSlideCanonicalComparison(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
forbidWrite(t, reg, "an unchanged page must not be written")
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo, noteKept))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["unchanged"] != true || data["parts_count"] != float64(0) {
|
||||
t.Fatalf("an identical page should report unchanged, got %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideDeletesDroppedElements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 9)
|
||||
|
||||
// bbv is dropped from the target page.
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne, noteKept))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("parts = %#v, want a single delete", parts)
|
||||
}
|
||||
if parts[0].BlockID != "bbv" || parts[0].Replacement != "" {
|
||||
t.Errorf("part = %+v, want an empty replacement on bbv (delete)", parts[0])
|
||||
}
|
||||
if data := decodeShortcutData(t, stdout); data["deleted"] != float64(1) {
|
||||
t.Errorf("deleted = %v, want 1", data["deleted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideInsertsNewElementsInPlace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 10)
|
||||
|
||||
// A new element without an id, written between the two existing ones.
|
||||
fresh := `<img src="tok" topLeftX="500" topLeftY="100" width="200" height="150"/>`
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+fresh+elemTwo, noteKept))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 || parts[0].Action != "block_insert" {
|
||||
t.Fatalf("parts = %#v, want a single block_insert", parts)
|
||||
}
|
||||
if parts[0].Insertion != fresh {
|
||||
t.Errorf("insertion = %q, want the caller's bytes", parts[0].Insertion)
|
||||
}
|
||||
// Position is expressed by naming the element it precedes; without it the
|
||||
// new element would land at the end of the page.
|
||||
if parts[0].InsertBeforeBlockID != "bbv" {
|
||||
t.Errorf("insert_before_block_id = %q, want bbv", parts[0].InsertBeforeBlockID)
|
||||
}
|
||||
if data := decodeShortcutData(t, stdout); data["inserted"] != float64(1) {
|
||||
t.Errorf("inserted = %v, want 1", data["inserted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideAppendsWhenNewElementIsLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 11)
|
||||
|
||||
fresh := `<img src="tok" width="100" height="100"/>`
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo+fresh, noteKept))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 || parts[0].InsertBeforeBlockID != "" {
|
||||
t.Fatalf("a trailing new element must be appended, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideRejectsBackgroundChange is the honest-failure case. The
|
||||
// background has no addressable element id, so the only alternative to an error
|
||||
// is returning success with the background untouched.
|
||||
func TestUpdateSlideRejectsBackgroundChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
style string
|
||||
wantWord string
|
||||
}{
|
||||
{
|
||||
name: "changed_fill",
|
||||
style: `<style><fill id="fiy"><fillColor color="rgba(255, 0, 0, 1)"/></fill></style>`,
|
||||
wantWord: "background",
|
||||
},
|
||||
{
|
||||
name: "style_dropped",
|
||||
style: ``,
|
||||
wantWord: "background",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
forbidWrite(t, reg, "a background change must be rejected before any write")
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(tt.style, elemOne+elemTwo, noteKept))
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--content" || !strings.Contains(ve.Message, tt.wantWord) {
|
||||
t.Fatalf("error should name --content and mention the background, got %q (param %q)", ve.Message, ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideHandlesNote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("replaced", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 12)
|
||||
|
||||
newNote := `<note id="bbb"><content><p>talk track</p></content></note>`
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo, newNote))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 || parts[0].BlockID != "bbb" || parts[0].Replacement != newNote {
|
||||
t.Fatalf("parts = %#v, want a block_replace on the note id", parts)
|
||||
}
|
||||
if data := decodeShortcutData(t, stdout); data["note_replaced"] != true {
|
||||
t.Errorf("note_replaced = %v, want true", data["note_replaced"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleared_when_omitted", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
// A page whose note has content, so omitting <note> is a real change.
|
||||
withNote := strings.Replace(currentPageXML, `<note id="bbb">
|
||||
<content/>
|
||||
</note>`, `<note id="bbb"><content><p>old note</p></content></note>`, 1)
|
||||
registerPageRead(t, reg, withNote)
|
||||
stub := registerWriteStub(t, reg, 13)
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 || parts[0].BlockID != "bbb" {
|
||||
t.Fatalf("parts = %#v, want the note cleared", parts)
|
||||
}
|
||||
if !strings.Contains(parts[0].Replacement, "<content/>") {
|
||||
t.Errorf("replacement = %q, want an empty note", parts[0].Replacement)
|
||||
}
|
||||
if data := decodeShortcutData(t, stdout); data["note_cleared"] != true {
|
||||
t.Errorf("note_cleared = %v, want true", data["note_cleared"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateSlideRejectsUnexpressibleEdits covers the edits that element-level
|
||||
// parts cannot describe. Each one must fail before any write rather than land
|
||||
// partially.
|
||||
func TestUpdateSlideRejectsUnexpressibleEdits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
content string
|
||||
wantWord string
|
||||
}{
|
||||
{
|
||||
name: "reordered_elements",
|
||||
content: wantPage(currentStyleXML, elemTwo+elemOne, noteKept),
|
||||
wantWord: "reorders",
|
||||
},
|
||||
{
|
||||
name: "unknown_element_id",
|
||||
content: wantPage(currentStyleXML, elemOne+elemTwo+`<shape id="bZZ" type="text"><content/></shape>`, noteKept),
|
||||
wantWord: "does not exist",
|
||||
},
|
||||
{
|
||||
name: "duplicate_element_id",
|
||||
content: wantPage(currentStyleXML, elemOne+elemOne+elemTwo, noteKept),
|
||||
wantWord: "twice",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
forbidWrite(t, reg, "an unexpressible edit must be rejected before any write")
|
||||
|
||||
err := runUpdateSlide(t, f, tt.content)
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantWord) {
|
||||
t.Fatalf("error message = %q, want it to mention %q", p.Message, tt.wantWord)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideRejectsBadContentBeforeReading pins that malformed input fails
|
||||
// without spending an API call.
|
||||
func TestUpdateSlideRejectsBadContentBeforeReading(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
content string
|
||||
wantWord string
|
||||
}{
|
||||
{name: "element_root", content: `<shape type="text"><content/></shape>`, wantWord: "+replace-slide"},
|
||||
{name: "presentation_root", content: `<presentation><slide id="piy"><data/></slide></presentation>`, wantWord: "+replace-pages"},
|
||||
{name: "second_page", content: `<slide id="p1"><data/></slide><slide id="p2"><data/></slide>`, wantWord: "one page per call"},
|
||||
{name: "trailing_text", content: `<slide id="p1"><data/></slide>oops`, wantWord: "text after"},
|
||||
{name: "not_xml", content: `not xml at all`, wantWord: "no root element"},
|
||||
{name: "unclosed", content: `<slide id="p1"><data>`, wantWord: "well-formed"},
|
||||
{name: "blank", content: ` `, wantWord: "cannot be empty"},
|
||||
// Slide-level structure the diff cannot represent: accepting any of
|
||||
// these would silently drop the edit — and could even answer
|
||||
// `unchanged` for a change the caller asked for.
|
||||
{name: "unknown_slide_child", content: `<slide id="piy"><data/><foo requestedChange="true"/></slide>`, wantWord: "unknown <foo>"},
|
||||
{name: "duplicate_data", content: `<slide id="piy"><data/><data><shape type="text"><content/></shape></data></slide>`, wantWord: "second <data>"},
|
||||
{name: "duplicate_style", content: `<slide id="piy"><style/><style/><data/></slide>`, wantWord: "second <style>"},
|
||||
{name: "duplicate_note", content: `<slide id="piy"><data/><note><content/></note><note><content/></note></slide>`, wantWord: "second <note>"},
|
||||
{name: "text_in_slide", content: `<slide id="piy"><data/>stray</slide>`, wantWord: "text directly inside <slide>"},
|
||||
{name: "text_in_data", content: `<slide id="piy"><data><shape type="text"><content/></shape>stray</data></slide>`, wantWord: "text directly inside <data>"},
|
||||
// XML fetched for page A posted against page B: the root id is the
|
||||
// only reliable cross-check — element-id checks catch it merely
|
||||
// incidentally, and an empty page would sail through them.
|
||||
{name: "root_id_mismatch", content: `<slide id="pother"><data/></slide>`, wantWord: "read from a different page"},
|
||||
// Container attributes have no element-level part to travel in, so
|
||||
// accepting them means an edit that silently vanishes into unchanged.
|
||||
{name: "root_attr", content: `<slide id="piy" requestedChange="true"><data/></slide>`, wantWord: `unsupported attribute "requestedChange" on <slide>`},
|
||||
{name: "data_attr", content: `<slide id="piy"><data requestedChange="true"/></slide>`, wantWord: `unsupported attribute "requestedChange" on <data>`},
|
||||
// Namespace declarations are not a loophole: an inherited binding
|
||||
// changes what every descendant name means, and the canonicalizer
|
||||
// compares local names, so a binding-only edit would vanish into
|
||||
// unchanged. Only the official SML default namespace may appear, and
|
||||
// only on the root.
|
||||
{name: "wrong_default_xmlns", content: `<slide xmlns="urn:not-sml" id="piy"><data/></slide>`, wantWord: `unsupported xmlns "urn:not-sml"`},
|
||||
{name: "prefixed_xmlns_on_slide", content: `<slide xmlns:x="urn:a" id="piy"><data/></slide>`, wantWord: `prefixed namespace declaration "xmlns:x"`},
|
||||
{name: "xmlns_on_data", content: `<slide id="piy"><data xmlns="http://www.larkoffice.com/sml/2.0"/></slide>`, wantWord: `on <data>`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/slide",
|
||||
Optional: true,
|
||||
OnMatch: func(*http.Request) { t.Error("malformed --content must fail before reading the page") },
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
err := runUpdateSlide(t, f, tt.content)
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantWord) {
|
||||
t.Fatalf("error message = %q, want it to mention %q", p.Message, tt.wantWord)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideRejectsUnsupportedCurrentPage covers the read side of the
|
||||
// structure guard: a page whose slide-level structure the diff cannot see
|
||||
// cannot be safely edited, because the comparison could neither preserve the
|
||||
// unrecognized part nor notice the caller changing it.
|
||||
func TestUpdateSlideRejectsUnsupportedCurrentPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, `<slide id="piy"><style/><data><shape id="bbD" type="text"><content/></shape></data><transition type="fade"/><note id="bbb"><content/></note></slide>`)
|
||||
forbidWrite(t, reg, "a page the diff cannot represent must never be written")
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage("<style/>", elemOne, noteKept))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a page with unrepresentable structure")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition (the page state, not the flag, is the problem)", ve.Subtype)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "<transition>") || !strings.Contains(ve.Message, "cannot represent") {
|
||||
t.Errorf("message should name the structure: %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideRootIDMayBeOmitted pins the other half of the root-id rule:
|
||||
// only a non-empty mismatching id is rejected; hand-written pages without one
|
||||
// apply normally.
|
||||
func TestUpdateSlideRootIDMayBeOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 23)
|
||||
|
||||
err := runUpdateSlide(t, f, `<slide>`+currentStyleXML+`<data>`+elemOneNew+elemTwo+`</data>`+noteKept+`</slide>`)
|
||||
if err != nil {
|
||||
t.Fatalf("a missing root id must be allowed: %v", err)
|
||||
}
|
||||
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 || parts[0].BlockID != "bbD" {
|
||||
t.Fatalf("parts = %#v, want the single font change", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideCanonicalAttrCollision is the regression for ambiguous
|
||||
// canonical encoding: with bare name=value concatenation these two elements
|
||||
// canonicalize identically, and the edit would be dropped as unchanged.
|
||||
func TestUpdateSlideCanonicalAttrCollision(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a, err := canonicalizeElement(`<img id="bbD" alt="foo rotateWithShape=true" src="x"/>`)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize a: %v", err)
|
||||
}
|
||||
b, err := canonicalizeElement(`<img id="bbD" alt="foo" rotateWithShape="true" src="x"/>`)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize b: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatalf("distinct attribute sets must not canonicalize identically:\n%s", a)
|
||||
}
|
||||
|
||||
// And through the whole command: the change must become a replacement part.
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, `<slide id="piy"><style/><data><img id="bbD" alt="foo rotateWithShape=true" src="x"/></data><note id="bbb"><content/></note></slide>`)
|
||||
stub := registerWriteStub(t, reg, 24)
|
||||
|
||||
err = runUpdateSlide(t, f, `<slide id="piy"><style/><data><img id="bbD" alt="foo" rotateWithShape="true" src="x"/></data><note id="bbb"><content/></note></slide>`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 || parts[0].BlockID != "bbD" {
|
||||
t.Fatalf("the attribute change must produce a replacement, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlidePlaceholderRefusal pins the conservative contract for
|
||||
// <undefined> placeholders — the server's stand-ins for objects it cannot
|
||||
// export (a whiteboard read without its export option, video/audio embeds).
|
||||
// Whether the whole-page rewrite preserves an untouched one is a server-owned
|
||||
// behavior no self-contained test can pin down (boards cannot be created
|
||||
// programmatically), so pages carrying one are refused outright rather than
|
||||
// edited on an unverifiable assumption.
|
||||
func TestUpdateSlidePlaceholderRefusal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentWithBoard := `<slide id="piy"><style/><data>` + elemOne + `<undefined id="bbW" type="whiteboard"/></data><note id="bbb"><content/></note></slide>`
|
||||
|
||||
t.Run("page_with_placeholder_is_refused", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentWithBoard)
|
||||
forbidWrite(t, reg, "a page carrying a placeholder must never be written")
|
||||
|
||||
// Even an edit that does not touch the placeholder is refused: the
|
||||
// rewrite behind the endpoint is whole-page, and preservation of the
|
||||
// placeholder is exactly what cannot be proven.
|
||||
err := runUpdateSlide(t, f, `<slide id="piy"><style/><data>`+elemOneNew+`<undefined id="bbW" type="whiteboard"/></data><note id="bbb"><content/></note></slide>`)
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition (the page state, not the flag, is the problem)", ve.Subtype)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "<undefined>") || !strings.Contains(ve.Message, "bbW") || !strings.Contains(ve.Message, "+replace-slide") {
|
||||
t.Errorf("message should name the placeholder and the escape hatch: %q", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("placeholder_in_content_is_refused", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML) // a clean page
|
||||
forbidWrite(t, reg, "hand-authored placeholders must be rejected before any write")
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+`<undefined type="whiteboard"/>`, noteKept))
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--content" || !strings.Contains(ve.Message, "<undefined>") {
|
||||
t.Fatalf("error = %q (param %q)", ve.Message, ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateSlideAcceptsSMLNamespaces pins that every namespace form the
|
||||
// repository's own SXSD validator accepts — the official identifier plus the
|
||||
// two server read-back spellings — round-trips on both sides of the diff. The
|
||||
// primary workflow copies `+xml-get` output back in, so rejecting a read-back
|
||||
// form would break the feature's core contract.
|
||||
func TestUpdateSlideAcceptsSMLNamespaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ns := range []string{
|
||||
"http://www.larkoffice.com/sml/2.0",
|
||||
"https://www.larkoffice.com/sml/2.0",
|
||||
"/sml/2.0",
|
||||
} {
|
||||
t.Run(ns, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
// The server itself may return the namespace on the read side.
|
||||
currentNS := `<slide xmlns="` + ns + `" id="piy">` + currentStyleXML + `<data>` + elemOne + elemTwo + `</data>` + noteKept + `</slide>`
|
||||
registerPageRead(t, reg, currentNS)
|
||||
stub := registerWriteStub(t, reg, 25)
|
||||
|
||||
err := runUpdateSlide(t, f, `<slide xmlns="`+ns+`" id="piy">`+currentStyleXML+`<data>`+elemOneNew+elemTwo+`</data>`+noteKept+`</slide>`)
|
||||
if err != nil {
|
||||
t.Fatalf("namespace %q must be accepted on both sides: %v", ns, err)
|
||||
}
|
||||
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 || parts[0].BlockID != "bbD" {
|
||||
t.Fatalf("parts = %#v, want the single font change", parts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideDetectsTextEdits is the regression for lossy text
|
||||
// canonicalization. Both edits were previously reported as `unchanged`: the
|
||||
// whitespace-only node between inline runs was trimmed away (dropping a
|
||||
// preserved   space, which decodes to the same token as a literal one),
|
||||
// and unescaped text let literal markup collide with real elements.
|
||||
func TestUpdateSlideDetectsTextEdits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
page := func(paragraph string) string {
|
||||
return `<slide id="piy"><style/><data><shape id="bbD" type="text"><content>` + paragraph + `</content></shape></data><note id="bbb"><content/></note></slide>`
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
current string
|
||||
wanted string
|
||||
}{
|
||||
{
|
||||
name: "space_between_inline_runs",
|
||||
current: page(`<p><strong>Hello</strong> <em>world</em></p>`),
|
||||
wanted: page(`<p><strong>Hello</strong><em>world</em></p>`),
|
||||
},
|
||||
{
|
||||
name: "escaped_literal_markup_vs_elements",
|
||||
current: page(`<p></p><p></p>`),
|
||||
wanted: page(`<p></p><p></p>`),
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// The pair must not canonicalize identically…
|
||||
a, err := canonicalizeElement(tt.current)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize current: %v", err)
|
||||
}
|
||||
b, err := canonicalizeElement(tt.wanted)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize wanted: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatalf("distinct content must not canonicalize identically:\n%s", a)
|
||||
}
|
||||
|
||||
// …and the edit must become a replacement, never `unchanged`.
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, tt.current)
|
||||
stub := registerWriteStub(t, reg, 26)
|
||||
|
||||
if err := runUpdateSlide(t, f, tt.wanted); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
|
||||
if len(parts) != 1 || parts[0].Action != "block_replace" || parts[0].BlockID != "bbD" {
|
||||
t.Fatalf("the text edit must produce a replacement, got %#v", parts)
|
||||
}
|
||||
if data := decodeShortcutData(t, stdout); data["unchanged"] == true {
|
||||
t.Fatal("a real text edit was reported as unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSlideFailedReasonIsAFailure pins that a rejected batch is reported
|
||||
// as a command failure rather than as a field on a success envelope.
|
||||
func TestUpdateSlideFailedReasonIsAFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/slide/replace",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"failed_reason": "block with id 'bbD' not found"},
|
||||
},
|
||||
})
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept))
|
||||
if err == nil {
|
||||
t.Fatal("a rejected batch must fail the command")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || !strings.Contains(p.Message, "not found") {
|
||||
t.Fatalf("error = %+v, want an API error carrying the backend reason", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideDryRunShowsBothCalls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/slide",
|
||||
Optional: true,
|
||||
OnMatch: func(*http.Request) { t.Error("--dry-run must not call the API") },
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesUpdateSlide, []string{
|
||||
"+update-slide",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "piy",
|
||||
"--content", wantPage(currentStyleXML, elemOne, noteKept),
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Both halves must be visible: the parts cannot be, because they depend on
|
||||
// the page's current state and dry-run must not fetch it.
|
||||
if !strings.Contains(out, `"GET"`) || !strings.Contains(out, `"POST"`) {
|
||||
t.Errorf("dry-run should show the read and the write: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/slide/replace") {
|
||||
t.Errorf("dry-run missing the write endpoint: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideForwardsRevisionAndTID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var readQuery, writeQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
OnMatch: func(req *http.Request) { readQuery = req.URL.RawQuery },
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide": map[string]interface{}{"content": currentPageXML}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/slide/replace",
|
||||
OnMatch: func(req *http.Request) { writeQuery = req.URL.RawQuery },
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 20}},
|
||||
})
|
||||
|
||||
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept),
|
||||
"--revision-id", "19", "--tid", "tid_9")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// The same revision is used for both calls so the parts apply to the
|
||||
// snapshot they were computed from.
|
||||
for name, query := range map[string]string{"read": readQuery, "write": writeQuery} {
|
||||
if !strings.Contains(query, "revision_id=19") {
|
||||
t.Errorf("%s query = %q, want revision_id=19", name, query)
|
||||
}
|
||||
if !strings.Contains(query, "tid=tid_9") {
|
||||
t.Errorf("%s query = %q, want tid=tid_9", name, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideAliasSharesBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 21)
|
||||
|
||||
err := runSlidesShortcut(t, f, nil, SlidesUpdate, []string{
|
||||
"+update",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "piy",
|
||||
"--content", wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 || parts[0].BlockID != "bbD" {
|
||||
t.Fatalf("alias sent %#v, want the same single part", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSlideAcceptsContentFlagAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sc := findSlidesShortcut(t, "+update-slide")
|
||||
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
registerPageRead(t, reg, currentPageXML)
|
||||
stub := registerWriteStub(t, reg, 22)
|
||||
|
||||
err := runSlidesShortcut(t, f, nil, sc, []string{
|
||||
"+update-slide",
|
||||
"--token", "pres_abc",
|
||||
"--slide-id", "piy",
|
||||
"--xml", wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--token / --xml aliases should resolve: %v", err)
|
||||
}
|
||||
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 {
|
||||
t.Fatalf("parts = %d, want 1", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
// findSlidesShortcut returns the registered shortcut for command, failing the
|
||||
// test when it is not wired into Shortcuts().
|
||||
func findSlidesShortcut(t *testing.T, command string) common.Shortcut {
|
||||
t.Helper()
|
||||
for _, sc := range Shortcuts() {
|
||||
if sc.Command == command {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %s is not registered in Shortcuts()", command)
|
||||
return common.Shortcut{}
|
||||
}
|
||||
|
||||
type updateSlidePart struct {
|
||||
Action string `json:"action"`
|
||||
BlockID string `json:"block_id"`
|
||||
Replacement string `json:"replacement"`
|
||||
Insertion string `json:"insertion"`
|
||||
InsertBeforeBlockID string `json:"insert_before_block_id"`
|
||||
}
|
||||
|
||||
func decodeUpdateSlideParts(t *testing.T, raw []byte) []updateSlidePart {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Parts []updateSlidePart `json:"parts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode body: %v\nraw=%s", err, raw)
|
||||
}
|
||||
return body.Parts
|
||||
}
|
||||
@@ -41,6 +41,7 @@ lark-cli auth login --domain apps
|
||||
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
|
||||
| 逐条执行 SQL(SELECT / DML / DDL);建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
|
||||
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
|
||||
| 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) |
|
||||
| **部署/上线应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push,再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 创意模式(html)应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |
|
||||
|
||||
61
skills/lark-apps/references/lark-apps-cache.md
Normal file
61
skills/lark-apps/references/lark-apps-cache.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# apps cache 域命令(应用运行时缓存调试)
|
||||
|
||||
调试妙搭应用的运行时缓存:查看某个缓存 key 的内容、删除单个 key、清空某个环境的全部缓存。缓存是应用为了加速而临时存放的数据,删除或清空后,应用下次用到时会自动重新取最新数据。命令事实以 `lark-cli apps +<cmd> --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户要排查「某个缓存 key 里存的是什么 / 有没有命中」、想删掉某个 key 让应用下次拿到最新数据、或想清空某个环境的缓存做快速恢复时。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 做什么 | 关键参数 |
|
||||
|---|---|---|
|
||||
| `+cache-get` | 查一个缓存 key 的内容与信息 | `--key`、`--environment`、`--format` |
|
||||
| `+cache-delete` | 删一个缓存 key(重复删不会报错;不需 `--yes`) | `--key`、`--environment` |
|
||||
| `+cache-clear` | 清空指定环境下的全部缓存(**高危**) | `--environment`、`--yes` |
|
||||
|
||||
> 所有命令都需 `--app-id`。
|
||||
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(可省略)**:缓存按运行环境隔离。不指定时按应用当前的环境配置自动选择——有多环境的应用默认落到开发环境 `dev`,没有多环境的就是线上 `online`;返回结果里的 `environment` 会告诉你这次实际操作的是哪个环境。想固定就显式传。
|
||||
- **缓存 key 用 `--key` 传**:传业务里使用的那个 key;是否合法(非空、长度等)由服务端校验,不合法会返回错误。
|
||||
- **风险分级**:`+cache-clear` 会清掉整个环境的缓存,是高危操作,不带 `--yes` 会被确认关卡拦下;`+cache-delete` 只删单个 key、影响小,不需 `--yes`。
|
||||
- **`+cache-get` 的内容有两种展示**:`--format json`(默认)原样返回缓存内容,适合精确比对;`--format pretty` 会把内容格式化展开,更便于阅读。
|
||||
|
||||
## 各命令
|
||||
|
||||
### +cache-get
|
||||
按 `--key` 查单个缓存。命中时返回:是否存在、剩余有效期(TTL)、内容及其大小;未命中(或已过期)时只返回 `exists=false`、不带内容。
|
||||
|
||||
> 每次查询都会连内容一起返回(没有「只看信息、不取内容」的模式),内容可能较大——只是想确认「在不在 / 还有多久过期」时,留意别占用太多上下文。
|
||||
|
||||
```bash
|
||||
lark-cli apps +cache-get --app-id app_xxx --key spotbonus:2026:winners:list:v1
|
||||
lark-cli apps +cache-get --app-id app_xxx --environment online --key <key> --format pretty
|
||||
```
|
||||
|
||||
### +cache-delete
|
||||
删一个缓存 key。**重复删、或删一个本就不存在的 key,都算成功**(返回删除数量 0)、不会报错;删中则返回删除数量 1。删掉后应用下次会自动重新取最新数据,影响小,故不需 `--yes`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +cache-delete --app-id app_xxx --environment dev --key <key>
|
||||
```
|
||||
|
||||
### +cache-clear(高危)
|
||||
清空当前应用在**指定环境**下的全部缓存,用于定位不到具体 key 时的快速恢复。影响面是整个环境,必须带 `--yes`;返回本次清除的 key 数量。动手前可先 `--dry-run` 预览将要执行的操作。
|
||||
|
||||
```bash
|
||||
lark-cli apps +cache-clear --app-id app_xxx --environment dev --yes
|
||||
```
|
||||
|
||||
## 错误与边界
|
||||
|
||||
- **key 不合法 / 缓存服务暂时不可用**:命令会返回带说明的错误,按 `error.hint` 转述给用户;「服务暂时不可用」这类可稍后重试。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- **写操作先定环境**:`+cache-clear` / `+cache-delete` 不指定 `--environment` 时会落到自动选中的环境——**没有多环境的应用会直接作用到线上 `online`(生产)**。不确定应用有没有多环境时,写操作显式传 `--environment`;纯查看(`+cache-get`)影响小,可以省略。
|
||||
- **`+cache-clear` 会清掉整个环境的缓存**:执行前先跟用户确认环境无误、说明会清掉该环境全部缓存。已明确授权可直接带 `--yes`;遇到确认关卡(`confirmation_required`,exit 10)按 lark-shared 约定与用户确认后再补 `--yes` 重试,不要静默追加。
|
||||
- **排查缓存内容优先用 `+cache-get`**:想看结构化、易读的内容用 `--format pretty`;想拿原始内容做精确比对用默认 JSON。
|
||||
- **删 key 前先对齐 key**:用户只描述了业务含义、没给准确 key 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.3
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -23,14 +23,15 @@ metadata:
|
||||
不要使用本 skill:
|
||||
|
||||
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`。
|
||||
- 把本地 Excel / CSV / `.base` 导入成 Base,转 `lark-drive +import --type bitable`。
|
||||
- 把本地文件导入成 Base,或将 Base 导出为本地文件,转 `lark-drive`。
|
||||
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
|
||||
|
||||
## 使用边界
|
||||
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置,首次请求必须基于可信的当前配置执行 read-modify-write:只修改用户明确指定的内容,保留其他仍适用的可写配置,并按命令要求的结构提交。若命令支持局部/delta update,按其契约提交最小合法 payload;不得以不完整请求试错补参。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 本地文件与 Base 之间的导入/导出转 `lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
|
||||
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
## 先获取 Base Token 和所需 ID
|
||||
@@ -49,6 +50,7 @@ metadata:
|
||||
|---|---|---|
|
||||
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
|
||||
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema;写入后报告新 Base 标识和 `permission_grant` |
|
||||
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
|
||||
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
|
||||
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 |
|
||||
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
|
||||
@@ -63,8 +65,9 @@ metadata:
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`。读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list` 或 `+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`,删除前确认目标表单 |
|
||||
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
@@ -116,6 +119,9 @@ metadata:
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list` 或 `+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id`。`+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`。
|
||||
- 表单问题由数据表字段承载,question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
|
||||
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`。
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
|
||||
@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 查看现有问题
|
||||
2. 确认要添加的问题内容
|
||||
3. 执行命令并报告新建的问题 ID
|
||||
1. 先确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`。
|
||||
2. 用 `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
|
||||
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
|
||||
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
|
||||
|
||||
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
|
||||
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
|
||||
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
|
||||
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
|
||||
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
|
||||
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
|
||||
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
|
||||
|
||||
## Required order
|
||||
|
||||
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
|
||||
|
||||
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
|
||||
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
|
||||
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
|
||||
|
||||
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
|
||||
|
||||
## Safety boundaries
|
||||
|
||||
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.
|
||||
|
||||
@@ -154,12 +154,34 @@
|
||||
"table_rule_map": {
|
||||
"订单表": {
|
||||
"perm": "edit",
|
||||
"view_rule": { "..." : "..." },
|
||||
"record_rule": { "..." : "..." },
|
||||
"field_rule": { "..." : "..." }
|
||||
"view_rule": {
|
||||
"allow_edit": true,
|
||||
"visibility": { "all_visible": true }
|
||||
},
|
||||
"record_rule": {
|
||||
"record_operations": ["add", "delete"],
|
||||
"other_record_all_read": true
|
||||
},
|
||||
"field_rule": {
|
||||
"field_perm_mode": "all_edit"
|
||||
}
|
||||
},
|
||||
"用户表": {
|
||||
"perm": "read_only"
|
||||
"perm": "read_only",
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
"visibility": { "all_visible": true }
|
||||
},
|
||||
"record_rule": {
|
||||
"record_operations": [],
|
||||
"other_record_all_read": true
|
||||
},
|
||||
"field_rule": {
|
||||
"field_perm_mode": "all_read"
|
||||
}
|
||||
},
|
||||
"内部表": {
|
||||
"perm": "no_perm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +194,11 @@
|
||||
| `record_rule` | RecordRule | 记录权限配置 |
|
||||
| `field_rule` | FieldRule | 字段权限配置 |
|
||||
|
||||
**注意**: 当 `perm` 为 `no_perm` 时,`view_rule`、`record_rule`、`field_rule` 均无须再设置。
|
||||
**`+role-create` 硬约束**:
|
||||
|
||||
- 当 `perm` 为 `no_perm` 时,不要设置 `view_rule`、`record_rule`、`field_rule`。
|
||||
- 当 `perm` 为其他值时,必须同时提供完整的 `view_rule`、`record_rule`、`field_rule`,缺少任意一项都会导致创建失败。
|
||||
- `+role-update` 是 delta merge,只提交要修改的字段;不要为局部更新补造未变更配置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ metadata:
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
@@ -121,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
|
||||
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
|
||||
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件的 PDF / HTML / 文本 / 图片等预览产物。 |
|
||||
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物。 |
|
||||
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
|
||||
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |
|
||||
|
||||
@@ -25,6 +25,10 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
|
||||
file_token
|
||||
```
|
||||
|
||||
## 排障
|
||||
|
||||
- 如果返回 `HTTP 403`,可以使用 [lark-drive-preview](lark-drive-preview.md) 下载源文件产物。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
|
||||
|
||||
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
|
||||
查看或下载 Drive 文件内容,或列出并获取文件可用的预览产物。这个 shortcut 不猜测默认类型:
|
||||
|
||||
- 如果只需要查看或下载文件内容,或不关心 PDF/text/image 等转换预览,优先使用 `--type source_file --output <path>`
|
||||
- 只想看候选项时,用 `--list-only`
|
||||
- 如果需要服务端生成的预览效果,例如 doc/docx 的 PDF 版式预览,先用 `--list-only` 查看候选项,再按候选项选择 `--type pdf` / `text` / `image` 等
|
||||
- 想下载时,必须显式传 `--type` 和 `--output`
|
||||
- 如果 `--list-only` 没有可用预览候选项,或错误提示明确建议使用 `--type source_file`,可以改用 `--type source_file --output <path>` 查看文件内容;资源不存在、token 无效等终态错误需要先修正输入
|
||||
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
# 查看文件内容
|
||||
lark-cli drive +preview \
|
||||
--file-token "<FILE_TOKEN>" \
|
||||
--type source_file \
|
||||
--output ./artifacts/source
|
||||
|
||||
# 列出可用预览候选项
|
||||
lark-cli drive +preview \
|
||||
--file-token "<FILE_TOKEN>" \
|
||||
@@ -78,6 +87,7 @@ lark-cli drive +preview \
|
||||
|
||||
- 不传 `--list-only` 时,必须显式传 `--type` 和 `--output`
|
||||
- 不会隐式选择“第一个候选项”作为默认下载目标
|
||||
- `--type source_file` 用于查看文件内容,不依赖 `--list-only` 返回的候选项;它适合读取或保存源内容,不等同于 PDF/text/image 等转换预览
|
||||
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
|
||||
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ metadata:
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`lark-slides-create.md`、`slides +create` |
|
||||
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 一页里大部分元素都要改(批量换字体 / 换配色 / 重排版式) | 先读回该页 XML,本地改完整交回去;CLI 做 diff 只发有差异的元素 | `slides +xml-get` → `slides +update-slide`、[`lark-slides-update-slide.md`](references/lark-slides-update-slide.md) |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
@@ -104,19 +103,13 @@ metadata:
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create`、`slides +replace-pages` 或 `slides +update-slide` 之前,MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。改字体、字号、宽高同样会改变文本度量和换行,属于必须过闸的编辑。**
|
||||
|
||||
**注意 `--revision-id` 不是乐观锁。** 实测传过期版本号服务端不会拒绝——它的含义是「在这个快照上应用改动」,钉住旧版本会丢弃该版本之后对这一页的所有编辑。**默认 `-1`(最新)就是推荐值。**
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);一页里大部分元素都要改(批量换字体、换配色、重排版式)用 [`+update-slide`](references/lark-slides-update-slide.md)(交整页 XML,CLI diff 后只改有差异的元素;`slide_id` 和页序不变,但改不了背景);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
**CRITICAL — 用 `+update-slide` 时,MUST 以 `slides +xml-get --slide-id <sid>` 读回的 XML 为基准修改,不要凭记忆手写整页**:`--content` 是这一页的目标状态——带原 `id` 的元素被更新、**不带 `id` 的当新元素插入**、原有但没出现在 `--content` 里的元素**被删除**、`<note>` 没出现则**备注被清空**。页面上有 `<undefined>` 占位符(画板、未导出的音视频)时 **`+update-slide` 会整页拒绝**(无法证明重写会保留它),该页改用 `+replace-slide` 做元素级编辑。手写整页即使渲染一致,也会变成大规模删建。
|
||||
|
||||
**CRITICAL — `+update-slide` 改不了页面背景,会直接报错而不是静默忽略。** 底层端点的 `block_id` 只收 `b` 开头的元素 id,而 `<style>` 没有自己的 id、`<fill>` 的 id 是 `f` 开头。把 `+xml-get` 读回的 `<style>` 原样保留即可;确实要改背景只能重建该页。同理**现有元素的顺序也不能调换**(没有 move 操作),会报错。整页更新还会**打散页面上所有组合且不可恢复**、把挂在非空 master / layout 的页面重新挂到空白 layout(与主题脱钩)——这些是端点行为,`+replace-slide` 同样触发,不是 `+update-slide` 独有。详见 [`lark-slides-update-slide.md`](references/lark-slides-update-slide.md)。
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
**用户要求使用模板**:按 [lark-slides-pptx-template-workflows.md](references/lark-slides-pptx-template-workflows.md) 处理。
|
||||
|
||||
@@ -154,7 +147,7 @@ lark-cli auth login --domain slides
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
|
||||
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-update-slide.md`](references/lark-slides-update-slide.md)(按整页 XML 更新一页)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
@@ -314,7 +307,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+update-slide`](references/lark-slides-update-slide.md) | 交一份完整 `<slide>` XML,CLI 读回当前页做 diff,只对有差异的元素发替换 / 新增 / 删除;`slide_id` 和页序不变。适合一页里多个元素都要改(批量换字体 / 配色 / 版式)。**改不了页面背景**,必须以 `+xml-get` 读回的 XML 为基准 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
@@ -334,7 +326,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;单页里多个元素都要改用 `+update-slide`(交整页 XML,CLI diff 后只改差异项,`slide_id` 不变,必须基于 `+xml-get` 读回的 XML,且改不了背景);已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊操作才手动 `slide.create` + `slide.delete`
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# slides +update-slide(按整页 XML 更新一页)
|
||||
|
||||
交一份完整的 `<slide>` XML,CLI 读回这一页当前的样子、和你给的做 diff,然后**只对有差异的元素**发替换 / 新增 / 删除。`slide_id` 和页序不变。
|
||||
|
||||
`slides +update` 是等价别名(不出现在 `--help` 里)。
|
||||
|
||||
## 为什么是 diff 而不是整页覆盖
|
||||
|
||||
底层端点 `xml_presentation.slide.replace` 的 part 里,`block_id` 被校验成**短元素 id(必须 `b` 开头)**。所以:
|
||||
|
||||
- 页面自己的 id 是 `p` 开头 → **不能**用一个 part 覆盖整页
|
||||
- 背景 fill 的 id 是 `f` 开头 → **不能**改背景
|
||||
|
||||
这两条都实测确认过(各自返回 3350001,而同一页上 `b` 开头的元素级 part 成功)。元素 id 是这个端点唯一的抓手,所以整页语义只能由 CLI 在客户端拆成元素级操作来表达。
|
||||
|
||||
**这带来一个硬限制:背景改不了。** 见下方「改不了的东西」。
|
||||
|
||||
## 什么时候用它,什么时候用 +replace-slide
|
||||
|
||||
| 场景 | 用哪个 |
|
||||
|------|--------|
|
||||
| 改一个标题、换一张图、动一个形状 | [`+replace-slide`](lark-slides-replace-slide.md),你已经知道要改哪个块,不需要 diff |
|
||||
| 一页里多个元素都要改(批量换字体 / 换配色 / 重排版式) | `+update-slide`,交整页 XML,不用逐块枚举 parts 和手写元素 XML |
|
||||
| 多页整页重建 | [`+replace-pages`](lark-slides-replace-pages.md) |
|
||||
| 新增一页 | `xml_presentation.slide create` |
|
||||
| 只改页面背景 | **本命令做不到**,见下 |
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 典型用法:读-改-写(--content 走文件,避免 shell 转义和长度问题)
|
||||
PID=slidesXXXXXXXXXXXXXXXXXXXXXX
|
||||
SID=piy
|
||||
|
||||
# 1) 读回这一页
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PID" --slide-id "$SID" --output .lark-slides/page.xml
|
||||
|
||||
# 2) 本地改(这里:把整页字体统一成思源黑体;-i.bak 写法 macOS / Linux 通用)
|
||||
sed -i.bak 's/fontFamily="[^"]*"/fontFamily="思源黑体"/g' .lark-slides/page.xml && rm .lark-slides/page.xml.bak
|
||||
|
||||
# 3) 版式准出检查(改字体会改变文本度量,必须过这一关)
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py .lark-slides/page.xml
|
||||
|
||||
# 4) 写回:CLI 自己再读一次做 diff,只发有差异的元素
|
||||
lark-cli slides +update-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" --content @.lark-slides/page.xml
|
||||
|
||||
# stdin 也可以
|
||||
cat .lark-slides/page.xml | lark-cli slides +update-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" --content -
|
||||
|
||||
# 预览(只显示会发哪两个请求;parts 取决于页面当前状态,dry-run 不读页面所以显示不了)
|
||||
lark-cli slides +update-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" --content @.lark-slides/page.xml --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/<token>` URL,或 `/wiki/<token>` URL(wiki 自动解析) |
|
||||
| `--slide-id` | 是 | 要更新的页面 ID |
|
||||
| `--content` | 是 | 整页 XML,**单个 `<slide>` 根元素**;支持 `@<file>` 和 `-`(stdin) |
|
||||
| `--revision-id` | 否 | 读取和应用所基于的版本;默认 `-1` = 最新。**不是乐观锁**,见下方「revision 不是乐观锁」 |
|
||||
| `--tid` | 否 | 并发事务 ID;多人协作长事务才用,单次单人调用留空 |
|
||||
|
||||
`--content` 也接受 `--xml` / `--slide-xml` / `--slide-content` / `--content-xml`,`--presentation` 也接受 `--token` / `--url` 等;不出现在 `--help` 里但传了能识别。**`--slide` 不是别名**——太容易和 `--slide-id` 混淆,故意没收录。
|
||||
|
||||
## 语义:`--content` 是这一页的目标状态
|
||||
|
||||
| 你写的 | 结果 |
|
||||
|---|---|
|
||||
| 元素带原 `id`、内容有变 | 替换该元素(`replaced`)|
|
||||
| 元素带原 `id`、内容没变 | 不动它,不产生 part |
|
||||
| 元素**不带 `id`** | 当新元素插入到你写的位置(`inserted`)|
|
||||
| 原有元素**没出现**在 `--content` 里 | 删除(`deleted`)|
|
||||
| `<note>` 有变 | 替换备注(`note_replaced`)|
|
||||
| `<note>` 没出现 | **清空备注**(`note_cleared`)|
|
||||
| 完全没有差异 | 不发写请求,返回 `unchanged: true` |
|
||||
|
||||
比较是**规范化**的:服务端返回的 XML 是 pretty-print、属性顺序被重排、还会注入你没写的样式默认值。CLI 比较时会把属性排序、忽略**结构元素之间**的排版空白,所以这些都不算变化——**原样读回、原样写回是幂等的**(已实测)。而发出去的替换内容是**你的原始字节**,你的格式和属性顺序会保留到页面里。
|
||||
|
||||
注意 **`<p>` 段落内的文本按原样比较**(包括空白):SML 里 ` ` 是"保留空格",解码后和普通空格是同一个字符,宁可把一个语义等价的空白变化多发一次替换,也不能把真实的 ` ` 编辑误判成"没变化"。
|
||||
|
||||
## 改不了的东西(会报错,不会静默)
|
||||
|
||||
| 你想做的 | 结果 | 为什么 |
|
||||
|---|---|---|
|
||||
| 改页面背景 / `<style>` | **报错** | `<style>` 自身没有 id,`<fill>` 的 id 是 `f` 开头,端点只收 `b` 开头 |
|
||||
| 调换现有元素的顺序 | **报错** | 没有 move 操作,元素级 part 表达不出来 |
|
||||
| `--content` 里写一个页面上不存在的 `id` | **报错** | CLI 不会替你造 id;想新建就**别写 id** |
|
||||
| 同一个 `id` 出现两次 | **报错** | — |
|
||||
| 根元素不是 `<slide>` | **报错** | `--content` 描述整页,传元素级片段会被理解成"这一页只剩这个",其余全删 |
|
||||
| 根元素 `id` 与 `--slide-id` 不一致 | **报错** | 大概率是拿错了页的 XML(读的 A 页、写的 B 页)。确实要跨页套用内容,就把根 `id` 去掉 |
|
||||
| `<slide>` 下出现 `style` / `data` / `note` 之外的子元素、或它们重复出现、或夹带文本 | **报错** | diff 表达不了这类结构;如果放过去,这部分改动会被静默丢弃,甚至误报 `unchanged` |
|
||||
| 给 `<slide>` 或 `<data>` 加属性 | **报错** | 容器属性没有可承载它的元素级 part。仅有的例外:`<slide>` 上可以带 SML namespace——接受与 `sxsd_validator.py` 相同的三种写法:`http://www.larkoffice.com/sml/2.0`、`https://www.larkoffice.com/sml/2.0`、`/sml/2.0`(或不带);其它 xmlns、前缀 `xmlns:x`、`<data>` 上的任何属性都会被拒绝 |
|
||||
| 编辑**任何**含 `<undefined>` 占位符的页面 | **报错** | 占位符是服务端对"导不出来的对象"(画板、未导出的音视频)的替身。整页重写是否会保留一个没被触碰的占位符,是服务端行为,**没有可编程复现的端点测试能证明它**(画板无法程序化创建),所以本命令直接拒绝编辑这类页面,而不是在无法验证的假设上动手。该页要改,用 [`+replace-slide`](lark-slides-replace-slide.md) 做元素级编辑 |
|
||||
| 一次传多页 | **报错** | 一页一次调用 |
|
||||
|
||||
背景确实要改的话,目前只能重建这一页(`slide create` + `slide delete`),或在客户端手动改。
|
||||
|
||||
## revision 不是乐观锁
|
||||
|
||||
**实测确认**:传一个已经过期的 `--revision-id` 服务端**不会拒绝**。它的含义是"在这个版本的快照上应用改动",然后把结果提交为新版本——所以钉住旧版本会把**该版本之后对这一页的所有编辑全部丢弃**。
|
||||
|
||||
所以:
|
||||
|
||||
- **默认 `-1`(最新)就是推荐值**,别去钉住你读到的那个 revision。`-1` 下你的 parts 应用在最新快照上,你没碰的元素保持别人的最新状态。
|
||||
- 只有在明确想"回到某个快照 + 我的改动"时才传具体版本号,并且清楚这会丢掉之后的编辑。
|
||||
- 想避免和别人抢同一页,靠的是 `--tid` 事务或流程约定,不是 `--revision-id`。
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"slide_id": "piy",
|
||||
"parts_count": 3,
|
||||
"replaced": 2,
|
||||
"inserted": 1,
|
||||
"deleted": 0,
|
||||
"revision_id": 103
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `parts_count` | 本次发出的元素级操作条数;`0` 表示没有差异 |
|
||||
| `replaced` / `inserted` / `deleted` | 分别替换、新增、删除了几个元素 |
|
||||
| `note_replaced` / `note_cleared` | 仅在备注被改 / 被清空时出现且为 `true` |
|
||||
| `unchanged` | 仅在完全没有差异时出现且为 `true`,此时没有发生写入 |
|
||||
| `revision_id` | 写入成功后的新版本号 |
|
||||
| `failed_reason` | 不会出现在成功返回里——批次被拒时整条命令失败 |
|
||||
|
||||
单次最多 200 个 part(服务端上限)。差异超过 200 个元素会在本地报错,让你拆开调用。
|
||||
|
||||
## 整页更新会连带影响的东西
|
||||
|
||||
下面这些是**这个端点**的行为,不是本命令引入的——[`+replace-slide`](lark-slides-replace-slide.md) 改一个元素也一样会触发(两者最终都走同一个整页 rewrite):
|
||||
|
||||
| 影响 | 说明 |
|
||||
|---|---|
|
||||
| **组合被打散** | 页面上所有 group 会被解除组合,且无法恢复——`<group>` 在读写两侧都不可表达 |
|
||||
| **主题挂载被改** | 页面原本挂在非空 master / layout 上时,会被重新挂到空白 layout,从此与主题脱钩;占位符会被清理 |
|
||||
| **动画丢失** | 被删除或被重建的元素上的动画会一并删掉(保住 `id` 的元素不受影响);`<smartLayout>` 每次都重建所以动画必丢。翻页转场不受影响 |
|
||||
| **静态图表换数据源** | 可编辑图表保留原有数据源;静态图表会拿到新 token,旧数据被弃用 |
|
||||
| **评论锚点** | `id` 匹配的元素上的评论保留;元素被删则锚点随之消失 |
|
||||
| **含 `<undefined>` 占位符的页面整页拒绝** | 画板、未导出的音视频读回来是 `<undefined>` 占位符;整页重写是否保留它无法用可复现的测试证明,所以本命令直接拒绝编辑这类页面(见上表)。[`+replace-slide`](lark-slides-replace-slide.md) 仍可对该页做元素级编辑 |
|
||||
|
||||
最后两条再次指向同一条建议:**以 `+xml-get` 的输出为基准做最小改动**,别手写整页。
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 现象 | 原因 | 对策 |
|
||||
|------|------|------|
|
||||
| `--content changes <style> (the page background)` | 改了背景 | 把 `+xml-get` 读回的 `<style>` 原样保留;确实要改背景只能重建该页 |
|
||||
| `--content reorders existing elements` | 调换了现有元素顺序 | 保持原顺序;要挪位置就删掉再以新元素插入 |
|
||||
| `element id "bZZ" ... does not exist` | 写了页面上不存在的 id | 想新建元素就**不要写 id**;或重新 `+xml-get` 确认 id |
|
||||
| `--content root element is <shape>` | 传了元素级片段 | 单个元素改动用 [`+replace-slide`](lark-slides-replace-slide.md);整页更新要补全 `<slide>` 外层 |
|
||||
| `--content root carries id "pold" but --slide-id is "pnew"` | 拿 A 页的 XML 写 B 页 | 重新对目标页 `+xml-get`;确实要跨页套用就去掉根 `id` |
|
||||
| `--content contains an unknown <foo> element` / `a second <data>` | `<slide>` 下有 diff 表达不了的结构 | 一页只有一个 `<style>`、一个 `<data>`、一个 `<note>`;把多余结构去掉 |
|
||||
| `slide piy contains an <undefined> placeholder` | 这一页上有画板或未导出的媒体对象 | 本命令拒绝编辑该页;用 [`+replace-slide`](lark-slides-replace-slide.md) 做元素级编辑 |
|
||||
| `an unsupported xmlns "…" on <slide>` | xmlns 写错或用了前缀声明 | 根元素接受 `sxsd_validator.py` 认可的三种 SML namespace(可不带);`<data>` 不收任何属性 |
|
||||
| `slide piy contains ... which this command cannot represent` | **当前页**(不是你的输入)带有本命令无法处理的结构 | 这一页改用 [`+replace-slide`](lark-slides-replace-slide.md) 做元素级编辑 |
|
||||
| `--content is not well-formed XML` | 括号没闭合、引号没配对、实体没转义 | 报错里带解析位置 |
|
||||
| 返回 `unchanged: true` 但你以为改了 | 你的改动被规范化比较判定为无差异(例如只动了缩进或属性顺序) | 检查是不是真的改了内容 |
|
||||
| 3350001 | 元素 XML 结构不合法,或嵌套 `<shape>` 缺 `<content/>` | 对照 [`xml-schema-quick-ref.md`](xml-schema-quick-ref.md)。注意 `+replace-slide` 会自动补 `<content/>`,本命令不会——元素 XML 原样发出 |
|
||||
| 大页面偶发失败,报错与排队 / 超时相关 | 服务端处理背压,不是尺寸超限 | 先重试;持续出现说明该页确实偏大,拆成几次 `+replace-slide` |
|
||||
| 403 | 权限不足 | 需要 `slides:presentation:update` 或 `write_only`,**以及 `slides:presentation:read`**(要先读页面);wiki URL 还需要 `wiki:node:read` |
|
||||
|
||||
## 相关命令
|
||||
|
||||
- [+xml-get](lark-slides-xml-presentations-get.md) — 读回整页 XML,本命令的输入来源
|
||||
- [+replace-slide](lark-slides-replace-slide.md) — 元素级替换 / 插入,已知目标块时用它
|
||||
- [+replace-pages](lark-slides-replace-pages.md) — 多页整页重建
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环 + 决策树
|
||||
@@ -55,3 +55,26 @@ func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
|
||||
assert.NotEqual(t, 0, result.ExitCode)
|
||||
assert.Contains(t, result.Stderr, "share-token")
|
||||
}
|
||||
|
||||
func TestBaseFormListDryRun_UsesBaseAndTableIdentifiers(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-list",
|
||||
"--base-token", "basXXXX",
|
||||
"--table-id", "tblXXXX",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/basXXXX/tables/tblXXXX/forms")
|
||||
assert.Contains(t, output, `"method": "GET"`)
|
||||
}
|
||||
|
||||
108
tests/cli_e2e/base/base_form_questions_create_dryrun_test.go
Normal file
108
tests/cli_e2e/base/base_form_questions_create_dryrun_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-create",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--form-id", "vew_x",
|
||||
"--questions", `[{"type":"text","title":"Risk","required":true}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_x/questions", clie2e.DryRunGet(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.questions.0.type").String(), out)
|
||||
require.Equal(t, "Risk", clie2e.DryRunGet(out, "api.0.body.questions.0.title").String(), out)
|
||||
require.True(t, clie2e.DryRunGet(out, "api.0.body.questions.0.required").Bool(), out)
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsCreateDryRunRejectsInvalidInput(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
message string
|
||||
}{
|
||||
{name: "malformed JSON", input: "{", message: "must be a valid JSON array"},
|
||||
{name: "non-array JSON", input: "{}", message: "must be a valid JSON array"},
|
||||
{name: "null", input: "null", message: "must be a non-null JSON array"},
|
||||
{name: "non-object item", input: "[1]", message: "item 1 must be an object"},
|
||||
{name: "missing title", input: `[{"type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
|
||||
{name: "blank title", input: `[{"title":" ","type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
|
||||
{name: "missing type", input: `[{"title":"Risk"}]`, message: `item 1 must include a non-empty string "type"`},
|
||||
{name: "non-string type", input: `[{"title":"Risk","type":1}]`, message: `item 1 must include a non-empty string "type"`},
|
||||
{name: "more than ten items", input: `[{},{},{},{},{},{},{},{},{},{},{}]`, message: "must contain at most 10 items"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-create",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--form-id", "vew_x",
|
||||
"--questions", tt.input,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--questions", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.message)
|
||||
require.Empty(t, result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsCreateHelpShowsExistingQuestionGuard(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+form-questions-create", "--help"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Contains(t, strings.ToLower(result.Stdout), "form may already contain questions")
|
||||
require.Contains(t, result.Stdout, "+form-questions-list")
|
||||
require.Contains(t, result.Stdout, "+form-questions-update")
|
||||
}
|
||||
30
tests/cli_e2e/base/base_skill_contract_test.go
Normal file
30
tests/cli_e2e/base/base_skill_contract_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseSkillRoutesFileImportExportToDrive(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
|
||||
skillPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "skills", "lark-base", "SKILL.md")
|
||||
content, err := vfs.ReadFile(skillPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
skill := string(content)
|
||||
require.Contains(t, skill, "文件导入/导出转 lark-drive")
|
||||
require.Contains(t, skill, "本地文件与 Base 之间的导入/导出转 `lark-drive`")
|
||||
require.Contains(t, skill, "在线复制走 `+base-copy`")
|
||||
require.NotContains(t, skill, "--only-schema")
|
||||
require.NotContains(t, skill, "--output-dir")
|
||||
require.NotContains(t, skill, "/tmp/")
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
# Base CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 78 leaf commands
|
||||
- Covered: 22
|
||||
- Coverage: 28.2%
|
||||
- Denominator: 87 leaf commands
|
||||
- Covered: 28
|
||||
- Coverage: 32.2%
|
||||
|
||||
## Summary
|
||||
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
|
||||
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
|
||||
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
|
||||
- TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help.
|
||||
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
|
||||
- TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling.
|
||||
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
|
||||
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
|
||||
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
@@ -34,6 +38,7 @@
|
||||
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
|
||||
| ✓ | base +dashboard-block-get-data | shortcut | base_dashboard_block_get_data_dryrun_test.go | `--base-token`; `--dashboard-id`; `--block-id`; dry-run only | request shape and identifier handling |
|
||||
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
|
||||
@@ -50,12 +55,14 @@
|
||||
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape |
|
||||
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-list | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✓ | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only |
|
||||
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
|
||||
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape |
|
||||
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
|
||||
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
|
||||
@@ -64,6 +71,7 @@
|
||||
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered |
|
||||
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
|
||||
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
|
||||
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
|
||||
@@ -78,6 +86,8 @@
|
||||
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
|
||||
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
|
||||
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
|
||||
| ✕ | base +title-resolve | shortcut | | none | resolver workflow not covered |
|
||||
| ✕ | base +url-resolve | shortcut | | none | resolver workflow not covered |
|
||||
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get | shortcut | | none | view workflows not covered |
|
||||
|
||||
@@ -93,6 +93,55 @@ func TestDrivePreviewDryRun_Download(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRun_SourceFile verifies source_file mode maps to a direct
|
||||
// source artifact download request.
|
||||
func TestDrivePreviewDryRun_SourceFile(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+preview",
|
||||
"--file-token", "fileDryRunPreview",
|
||||
"--type", "source_file",
|
||||
"--version", "12",
|
||||
"--output", "./artifacts/source",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
|
||||
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
|
||||
t.Fatalf("url=%q, want preview download endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.params.preview_type").String(); got != "16" {
|
||||
t.Fatalf("preview_type=%q, want 16\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.params.version").String(); got != "12" {
|
||||
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "requested_type").String(); got != "source_file" {
|
||||
t.Fatalf("requested_type=%q, want source_file\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "selected_type").String(); got != "source_file" {
|
||||
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "selected_type_code").String(); got != "16" {
|
||||
t.Fatalf("selected_type_code=%q, want 16\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
|
||||
// download mode.
|
||||
func TestDriveCoverDryRun_Download(t *testing.T) {
|
||||
|
||||
@@ -35,6 +35,41 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
|
||||
|
||||
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
|
||||
|
||||
t.Run("source file download", func(t *testing.T) {
|
||||
downloadDir := t.TempDir()
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+preview",
|
||||
"--file-token", fileToken,
|
||||
"--type", "source_file",
|
||||
"--output", "./artifacts/report-source",
|
||||
},
|
||||
WorkDir: downloadDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
stdout := downloadResult.Stdout
|
||||
if gjson.Get(stdout, "data.requested_type").Exists() {
|
||||
t.Fatalf("requested_type should be omitted from execute output\nstdout:\n%s", stdout)
|
||||
}
|
||||
if got := gjson.Get(stdout, "data.selected_type").String(); got != "source_file" {
|
||||
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, stdout)
|
||||
}
|
||||
if gjson.Get(stdout, "data.selected_type_code").Exists() {
|
||||
t.Fatalf("selected_type_code should be omitted from execute output\nstdout:\n%s", stdout)
|
||||
}
|
||||
outputPath := gjson.Get(stdout, "data.output_path").String()
|
||||
require.NotEmpty(t, outputPath, "source file preview should return output_path")
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
require.NoError(t, readErr)
|
||||
if string(data) != sourceContent {
|
||||
t.Fatalf("source file preview content=%q want %q", string(data), sourceContent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preview list and download", func(t *testing.T) {
|
||||
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
|
||||
@@ -149,11 +149,14 @@ func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
|
||||
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
|
||||
assert.Contains(t, output, `"version": "7633658129540910621"`)
|
||||
assert.Contains(t, output, `"version": "7633658129540910628"`)
|
||||
assert.Contains(t, output, `"context_lines": 1`)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
|
||||
require.Equal(t, "remote_vs_remote", clie2e.DryRunGet(output, "mode").String(), output)
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(output, "api.#").Int(), output)
|
||||
require.Equal(t, "16", clie2e.DryRunGet(output, "api.0.params.preview_type").String(), output)
|
||||
require.Equal(t, "7633658129540910621", clie2e.DryRunGet(output, "api.0.params.version").String(), output)
|
||||
require.Equal(t, "16", clie2e.DryRunGet(output, "api.1.params.preview_type").String(), output)
|
||||
require.Equal(t, "7633658129540910628", clie2e.DryRunGet(output, "api.1.params.version").String(), output)
|
||||
require.Equal(t, int64(1), clie2e.DryRunGet(output, "context_lines").Int(), output)
|
||||
}
|
||||
|
||||
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
|
||||
@@ -179,8 +182,9 @@ func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
|
||||
assert.Contains(t, output, `"mode": "remote_vs_local"`)
|
||||
assert.Contains(t, output, `"preview_type": "16"`)
|
||||
assert.Contains(t, output, `"local_file": "./draft.md"`)
|
||||
}
|
||||
|
||||
@@ -224,7 +228,8 @@ func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
|
||||
assert.Contains(t, output, `"preview_type": "16"`)
|
||||
assert.Contains(t, output, `"output": "./copy.md"`)
|
||||
}
|
||||
|
||||
@@ -305,7 +310,8 @@ func TestMarkdownPatchDryRun_Content(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
|
||||
assert.Contains(t, output, `"preview_type": "16"`)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# Slides CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 3 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 66.7%
|
||||
- Denominator: 2 leaf commands
|
||||
- Covered: 1
|
||||
- Coverage: 50.0%
|
||||
|
||||
## Summary
|
||||
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
|
||||
- TestSlides_UpdateSlideWorkflowAsUser: proves `+update-slide` end to end on a two-page deck — restyle an element (one replace part), rewrite the same page (no-op, no request), add an element without an id (insert), drop an element (delete), and confirm a background change is refused with the page left untouched; then checks the control page and the deck order did not move. **This test is why the command works**: the first version of the command sent a single part covering the whole page, which HTTP stubs accepted and the real API rejects outright — `ReplacePart.block_id` is validated as a short ELEMENT id, so neither the page id (`p`-prefixed) nor the background fill id (`f`-prefixed) can be addressed. Stubs prove the request shape; only a live round trip proves the request is legal.
|
||||
- TestSlidesUpdateSlideDryRunE2E / TestSlidesUpdateAliasDryRunE2E / TestSlidesUpdateSlideRejectsElementRootDryRunE2E: dry-run coverage for the read-then-write orchestration, the shared revision and slide_id on both calls, the hidden `slides +update` alias with the hidden `--token` / `--xml` spellings, and the refusal of a non-`<slide>` root before any request is built.
|
||||
- **Known gap**: the live workflow test skips without a user token, so a CI run configured only with bot credentials leaves the load-bearing backend behavior unverified — exactly the blind spot that let the original design reach review. A CI user token, or a bot-identity variant of this workflow, would close it.
|
||||
- Cleanup deletes the deck through `drive +delete`, which needs `space:document:delete` and `drive:drive.metadata:readonly`. For **stored credentials** the workflow probes that capability up front with a `--dry-run` delete (whose scope pre-flight reads the stored grants) and skips before creating anything when they are missing; an unexpected probe failure is fatal. For **environment tokens** (`TEST_USER_ACCESS_TOKEN`) no scope metadata exists and no API exposes a token's grants without exercising them, so the probe proves nothing there — the CI identity must be provisioned with the cleanup scopes, and a cleanup failure stays fatal and visible. A fully-scoped run creates, edits and deletes its own deck (verified green end to end).
|
||||
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
|
||||
|
||||
## Command Table
|
||||
@@ -18,5 +14,4 @@
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
|
||||
| ✓ | slides +update-slide | shortcut | slides_update_slide_workflow_test.go::TestSlides_UpdateSlideWorkflowAsUser | `--presentation`; `--slide-id`; `--content "<slide ...>"`; `--revision-id` | live run needs a user token carrying `slides:presentation:create` / `read` / `update` / `write_only`, plus `space:document:delete` for cleanup; the dry-run half needs no secrets |
|
||||
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const updateSlideDryRunPageXML = `<slide id="piy"><style><fill id="fiy"><fillColor color="rgba(255, 255, 255, 1)"/></fill></style><data><shape id="bRU" type="text" topLeftX="46" topLeftY="34" width="400" height="36"><content textType="headline" fontSize="28"><p>Overview</p></content></shape></data><note id="bno"><content/></note></slide>`
|
||||
|
||||
// TestSlidesUpdateSlideDryRunE2E pins the shape of the orchestration through the
|
||||
// built CLI: the command reads the page before writing it, because the parts it
|
||||
// sends are derived from the page's current state rather than from --content
|
||||
// alone. Dry-run deliberately cannot show the parts — computing them would
|
||||
// require the read it is not allowed to perform.
|
||||
func TestSlidesUpdateSlideDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+update-slide",
|
||||
"--presentation", "presUpdateSlideDryRun",
|
||||
"--slide-id", "piy",
|
||||
"--content", updateSlideDryRunPageXML,
|
||||
"--revision-id", "17",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
api := gjson.Get(result.Stdout, "data.api").Array()
|
||||
require.Len(t, api, 2, "the command reads then writes\n%s", result.Stdout)
|
||||
|
||||
require.Equal(t, "GET", api[0].Get("method").String(), result.Stdout)
|
||||
require.Equal(t,
|
||||
"/open-apis/slides_ai/v1/xml_presentations/presUpdateSlideDryRun/slide",
|
||||
api[0].Get("url").String(), result.Stdout,
|
||||
)
|
||||
require.Equal(t, "POST", api[1].Get("method").String(), result.Stdout)
|
||||
require.Equal(t,
|
||||
"/open-apis/slides_ai/v1/xml_presentations/presUpdateSlideDryRun/slide/replace",
|
||||
api[1].Get("url").String(), result.Stdout,
|
||||
)
|
||||
|
||||
// The same revision is used for both calls so the parts apply to the
|
||||
// snapshot they were diffed against.
|
||||
for i := range api {
|
||||
require.Equal(t, "piy", api[i].Get("params.slide_id").String(), result.Stdout)
|
||||
require.Equal(t, int64(17), api[i].Get("params.revision_id").Int(), result.Stdout)
|
||||
require.False(t, api[i].Get("params.tid").Exists(), "tid must be omitted when empty\n%s", result.Stdout)
|
||||
}
|
||||
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.wanted_element_count").Int(), result.Stdout)
|
||||
}
|
||||
|
||||
// TestSlidesUpdateAliasDryRunE2E proves the hidden `+update` spelling reaches
|
||||
// the same logic through the real CLI, along with the hidden --token / --xml
|
||||
// flag spellings.
|
||||
func TestSlidesUpdateAliasDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+update",
|
||||
"--token", "presUpdateSlideDryRun",
|
||||
"--slide-id", "piy",
|
||||
"--xml", updateSlideDryRunPageXML,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t,
|
||||
"/open-apis/slides_ai/v1/xml_presentations/presUpdateSlideDryRun/slide/replace",
|
||||
gjson.Get(result.Stdout, "data.api.1.url").String(),
|
||||
result.Stdout,
|
||||
)
|
||||
require.Equal(t, int64(-1), gjson.Get(result.Stdout, "data.api.1.params.revision_id").Int(),
|
||||
"-1 is the default: apply against the latest revision\n%s", result.Stdout)
|
||||
}
|
||||
|
||||
// TestSlidesUpdateSlideRejectsBadContentDryRunE2E is the guardrail check
|
||||
// through the built CLI: inputs whose failure mode is data loss must be
|
||||
// refused before any request is built, with the full typed error contract —
|
||||
// agents branch on type/subtype/param, not on prose.
|
||||
func TestSlidesUpdateSlideRejectsBadContentDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
content string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
// An element-level fragment would mean "the page should contain
|
||||
// only this" and delete everything else.
|
||||
name: "element_root",
|
||||
content: `<shape type="text"><content><p>oops</p></content></shape>`,
|
||||
wantMessage: "+replace-slide",
|
||||
},
|
||||
{
|
||||
// XML fetched for page A posted against page B.
|
||||
name: "root_id_mismatch",
|
||||
content: `<slide id="pother"><data/></slide>`,
|
||||
wantMessage: "read from a different page",
|
||||
},
|
||||
{
|
||||
// Slide-level structure the diff cannot represent would be
|
||||
// silently dropped — possibly reported as `unchanged`.
|
||||
name: "unknown_slide_child",
|
||||
content: `<slide id="piy"><data/><foo requestedChange="true"/></slide>`,
|
||||
wantMessage: "unknown <foo>",
|
||||
},
|
||||
{
|
||||
// Container attributes have no element-level part to travel in.
|
||||
name: "root_attribute",
|
||||
content: `<slide id="piy" requestedChange="true"><data/></slide>`,
|
||||
wantMessage: "unsupported attribute",
|
||||
},
|
||||
{
|
||||
// A namespace binding inherited from the root changes what every
|
||||
// descendant name means; only the official SML declaration passes.
|
||||
name: "wrong_default_xmlns",
|
||||
content: `<slide xmlns="urn:not-sml" id="piy"><data/></slide>`,
|
||||
wantMessage: "unsupported xmlns",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+update-slide",
|
||||
"--presentation", "presUpdateSlideDryRun",
|
||||
"--slide-id", "piy",
|
||||
"--content", tt.content,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
// RunCmd errors only when the CLI could not be launched; a normal
|
||||
// non-zero exit lands in result.ExitCode. Discarding this error
|
||||
// would turn a broken harness into a nil-pointer panic.
|
||||
require.NoError(t, err, "the CLI must launch")
|
||||
// Validation errors have a fixed process contract: exit code 2,
|
||||
// nothing on stdout, the typed envelope on stderr.
|
||||
require.Equal(t, 2, result.ExitCode,
|
||||
"stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
require.Empty(t, result.Stdout, "a refused command must not emit a result")
|
||||
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--content", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.wantMessage, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// skipWithoutCleanupScopes refuses to create a deck this run cannot delete,
|
||||
// when that is knowable. AGENTS.md wants live workflows self-contained
|
||||
// (create → use → cleanup); a cleanup that fails on missing scopes both fails
|
||||
// the package after the workflow already passed and leaks one presentation per
|
||||
// run. The capability is probed up front with --dry-run, which runs the same
|
||||
// scope pre-flight as the real cleanup without touching anything remote.
|
||||
//
|
||||
// The probe is authoritative only for stored credentials, whose scope grants
|
||||
// the pre-flight can read. A token injected through the environment
|
||||
// (TEST_USER_ACCESS_TOKEN / LARKSUITE_CLI_USER_ACCESS_TOKEN) carries no scope
|
||||
// metadata, and the pre-flight deliberately skips when scopes are unknown —
|
||||
// exit 0 from the probe proves nothing there. No API exposes a token's grants
|
||||
// without exercising them, so for that path the run proceeds on the documented
|
||||
// requirement that the CI identity is provisioned with the cleanup scopes
|
||||
// (coverage.md), and a cleanup failure stays fatal and visible.
|
||||
func skipWithoutCleanupScopes(ctx context.Context, t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("TEST_USER_ACCESS_TOKEN") != "" || os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" {
|
||||
t.Log("cleanup-scope probe skipped: environment tokens carry no scope metadata, so a dry-run pre-flight cannot prove anything; the CI identity must be provisioned with space:document:delete and drive:drive.metadata:readonly (see coverage.md)")
|
||||
return
|
||||
}
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", "cleanup_scope_probe", "--type", "slides", "--yes", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "the CLI must launch for the scope probe")
|
||||
switch {
|
||||
case result.ExitCode == 0:
|
||||
// Stored credential with the scopes present.
|
||||
case strings.Contains(result.Stderr, "missing_scope"):
|
||||
t.Skipf("user token lacks the cleanup scopes (space:document:delete, drive:drive.metadata:readonly); refusing to create a deck the run cannot delete\nstderr:\n%s", result.Stderr)
|
||||
default:
|
||||
// Anything else is a broken probe, not a known-good capability;
|
||||
// proceeding would risk creating a deck under unknown conditions.
|
||||
t.Fatalf("cleanup-scope probe failed unexpectedly (exit %d)\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlides_UpdateSlideWorkflowAsUser is the only test that can prove this
|
||||
// command works, and it exists because an earlier version of it did not: the
|
||||
// whole design once rested on sending a single part covering the page, which
|
||||
// unit stubs happily accepted and the real API rejects outright (block_id is
|
||||
// validated as a short ELEMENT id, so a page id cannot be addressed). Stubs
|
||||
// prove the request shape; only a live round trip proves the request is legal
|
||||
// and does what it claims.
|
||||
//
|
||||
// It walks every operation the diff can emit — replace, insert, delete, and the
|
||||
// no-op — then checks the two things element-level parts cannot express are
|
||||
// refused rather than silently dropped.
|
||||
func TestSlides_UpdateSlideWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Without a user token the load-bearing backend behavior goes unverified in
|
||||
// this run; say so rather than skipping quietly.
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
skipWithoutCleanupScopes(ctx, t)
|
||||
|
||||
parentT := t
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
title := "slides-update-e2e-" + suffix
|
||||
controlText := "Control " + suffix
|
||||
originalText := "Original " + suffix
|
||||
|
||||
page := func(body string) string {
|
||||
return `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>` +
|
||||
`<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">` +
|
||||
`<content textType="title"><p>` + body + `</p></content></shape></data></slide>`
|
||||
}
|
||||
jsonArray := func(xmls ...string) string {
|
||||
quoted := make([]string, 0, len(xmls))
|
||||
for _, xml := range xmls {
|
||||
quoted = append(quoted, `"`+strings.ReplaceAll(xml, `"`, `\"`)+`"`)
|
||||
}
|
||||
return "[" + strings.Join(quoted, ",") + "]"
|
||||
}
|
||||
readPage := func(t *testing.T, presentationID, slideID string) string {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"slides", "+xml-get", "--presentation", presentationID, "--slide-id", slideID},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
content := gjson.Get(result.Stdout, "data.slide.content").String()
|
||||
require.NotEmpty(t, content, "stdout:\n%s", result.Stdout)
|
||||
return content
|
||||
}
|
||||
update := func(t *testing.T, presentationID, slideID, content string) *clie2e.Result {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+update-slide",
|
||||
"--presentation", presentationID,
|
||||
"--slide-id", slideID,
|
||||
"--content", content,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
var presentationID, targetSlideID string
|
||||
|
||||
t.Run("create a two-page presentation as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create",
|
||||
"--title", title,
|
||||
"--slides", jsonArray(page(controlText), page(originalText)),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
|
||||
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
|
||||
slideIDs := gjson.Get(result.Stdout, "data.slide_ids").Array()
|
||||
require.Len(t, slideIDs, 2, "stdout:\n%s", result.Stdout)
|
||||
targetSlideID = slideIDs[1].String()
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+delete",
|
||||
"--file-token", presentationID,
|
||||
"--type", "slides",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
// Deleting needs space:document:delete, which a token scoped only
|
||||
// for slides does not carry; report rather than fail so a scope gap
|
||||
// does not mask the workflow result. The deck is named with the
|
||||
// run suffix so a leftover is identifiable.
|
||||
clie2e.ReportCleanupFailure(parentT, "delete presentation "+presentationID, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("restyle an element: one replace part", func(t *testing.T) {
|
||||
require.NotEmpty(t, targetSlideID, "presentation should be created first")
|
||||
|
||||
// Change the font on every element, which is the edit the command
|
||||
// exists for. Only <content> is touched, so exactly one element differs.
|
||||
current := readPage(t, presentationID, targetSlideID)
|
||||
wanted := regexp.MustCompile(`fontFamily="[^"]*"`).ReplaceAllString(current, `fontFamily="楷体"`)
|
||||
require.NotEqual(t, current, wanted, "the page should have had a fontFamily to change:\n%s", current)
|
||||
|
||||
result := update(t, presentationID, targetSlideID, wanted)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.replaced").Int(), result.Stdout)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.parts_count").Int(), result.Stdout)
|
||||
require.Equal(t, targetSlideID, gjson.Get(result.Stdout, "data.slide_id").String(),
|
||||
"the page keeps its slide_id\n%s", result.Stdout)
|
||||
|
||||
after := readPage(t, presentationID, targetSlideID)
|
||||
require.Contains(t, after, `fontFamily="楷体"`, "the restyle must be live:\n%s", after)
|
||||
require.Contains(t, after, originalText, "restyling must not change the text:\n%s", after)
|
||||
})
|
||||
|
||||
t.Run("writing the same page again is a no-op", func(t *testing.T) {
|
||||
current := readPage(t, presentationID, targetSlideID)
|
||||
result := update(t, presentationID, targetSlideID, current)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.True(t, gjson.Get(result.Stdout, "data.unchanged").Bool(),
|
||||
"an identical page must not be written\n%s", result.Stdout)
|
||||
require.Equal(t, int64(0), gjson.Get(result.Stdout, "data.parts_count").Int(), result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("add an element without an id: one insert part", func(t *testing.T) {
|
||||
current := readPage(t, presentationID, targetSlideID)
|
||||
added := `<shape type="text" topLeftX="80" topLeftY="300" width="400" height="80"><content><p>Added ` + suffix + `</p></content></shape>`
|
||||
wanted := strings.Replace(current, "</data>", added+"</data>", 1)
|
||||
|
||||
result := update(t, presentationID, targetSlideID, wanted)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.inserted").Int(), result.Stdout)
|
||||
|
||||
after := readPage(t, presentationID, targetSlideID)
|
||||
require.Contains(t, after, "Added "+suffix, "the new element must be live:\n%s", after)
|
||||
require.Contains(t, after, originalText, "the existing element must survive:\n%s", after)
|
||||
})
|
||||
|
||||
t.Run("drop an element: one delete part", func(t *testing.T) {
|
||||
current := readPage(t, presentationID, targetSlideID)
|
||||
// Remove the original title shape, keeping the one added above.
|
||||
wanted := regexp.MustCompile(`(?s)\s*<shape[^>]*>\s*<content[^>]*>\s*<p>`+regexp.QuoteMeta(originalText)+`</p>.*?</shape>`).
|
||||
ReplaceAllString(current, "")
|
||||
require.NotEqual(t, current, wanted, "the title shape should have been removable:\n%s", current)
|
||||
|
||||
result := update(t, presentationID, targetSlideID, wanted)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.deleted").Int(), result.Stdout)
|
||||
|
||||
after := readPage(t, presentationID, targetSlideID)
|
||||
require.NotContains(t, after, originalText, "the dropped element must be gone:\n%s", after)
|
||||
require.Contains(t, after, "Added "+suffix, "the kept element must remain:\n%s", after)
|
||||
})
|
||||
|
||||
t.Run("a background change is refused, not dropped", func(t *testing.T) {
|
||||
current := readPage(t, presentationID, targetSlideID)
|
||||
// Matches both the self-closing <style/> a plain page comes back with
|
||||
// and a populated <style>…</style>.
|
||||
styleBlock := regexp.MustCompile(`(?s)<style\s*/>|<style[^>]*>.*?</style>`)
|
||||
require.True(t, styleBlock.MatchString(current), "page should carry a <style> block:\n%s", current)
|
||||
wanted := styleBlock.ReplaceAllString(current,
|
||||
`<style><fill><fillColor color="rgba(255, 0, 0, 1)"/></fill></style>`)
|
||||
|
||||
result := update(t, presentationID, targetSlideID, wanted)
|
||||
require.NotEqual(t, 0, result.ExitCode,
|
||||
"the background cannot be expressed, so it must fail loudly\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
require.Contains(t, result.Stderr, "background", "stderr:\n%s", result.Stderr)
|
||||
|
||||
// And nothing may have been written: the page is still what it was.
|
||||
require.Equal(t, current, readPage(t, presentationID, targetSlideID),
|
||||
"a refused background change must leave the page untouched")
|
||||
})
|
||||
|
||||
t.Run("the other page and the page order are untouched", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"revision_id": -1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
|
||||
require.Contains(t, content, controlText, "the control page must be untouched\n%s", content)
|
||||
|
||||
controlAt := strings.Index(content, controlText)
|
||||
editedAt := strings.Index(content, "Added "+suffix)
|
||||
require.GreaterOrEqual(t, controlAt, 0, content)
|
||||
require.GreaterOrEqual(t, editedAt, 0, content)
|
||||
require.Less(t, controlAt, editedAt, "the deck order must not change\n%s", content)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user