mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
24 Commits
v1.0.79
...
feat/white
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e6bc2f3b | ||
|
|
1fd29e75a6 | ||
|
|
5cf09ecfda | ||
|
|
41692b7041 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
87be09ef5f | ||
|
|
a575a8ba60 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -2,6 +2,27 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
@@ -1701,6 +1722,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
|
||||
@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
|
||||
@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||
// failed refresh leaves the old value in place), so it can name a version
|
||||
// that is no longer the one npm would install. The version actually
|
||||
// installed is resolved live by the update subcommand, which prints
|
||||
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||
// that is where the user sees the real target. Keep going through the
|
||||
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||
// that line disappears and the user approves a global install without ever
|
||||
// being told what gets installed.
|
||||
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
// The prompt must not name a target version: info.Latest comes from
|
||||
// the on-disk cache and can be stale, while the version actually
|
||||
// installed is resolved live by the update subcommand.
|
||||
if tc.wantPrompt {
|
||||
if strings.Contains(errBuf.String(), tc.latest) {
|
||||
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), build.Version) {
|
||||
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
|
||||
}
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
|
||||
@@ -159,8 +159,7 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
|
||||
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(acct.Brand)))
|
||||
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +57,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -197,14 +202,27 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
|
||||
// ShortcutHeaders extracts Shortcut info from the context and returns
|
||||
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
|
||||
func ShortcutHeaders(ctx context.Context) http.Header {
|
||||
name, ok := ShortcutNameFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
h := make(http.Header)
|
||||
h.Set(HeaderShortcut, name)
|
||||
if eid, ok := ExecutionIdFromContext(ctx); ok {
|
||||
h.Set(HeaderExecutionId, eid)
|
||||
if name, ok := ShortcutNameFromContext(ctx); ok {
|
||||
h.Set(HeaderShortcut, name)
|
||||
if eid, ok := ExecutionIdFromContext(ctx); ok {
|
||||
h.Set(HeaderExecutionId, eid)
|
||||
}
|
||||
}
|
||||
if name, value := extraHeaderFromEnv(); name != "" && value != "" {
|
||||
h.Set(name, value)
|
||||
}
|
||||
if len(h) == 0 {
|
||||
return nil
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func extraHeaderFromEnv() (string, string) {
|
||||
name := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderName))
|
||||
value := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderValue))
|
||||
if name == "" || value == "" {
|
||||
return "", ""
|
||||
}
|
||||
return name, value
|
||||
}
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -61,5 +66,8 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
|
||||
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
if override := strings.TrimRight(strings.TrimSpace(os.Getenv(envvars.CliOpenBaseURL)), "/"); override != "" {
|
||||
return override
|
||||
}
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
@@ -58,6 +58,13 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL_EnvOverride(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_OPEN_BASE_URL", "https://open.feishu-boe.cn/")
|
||||
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu-boe.cn" {
|
||||
t.Errorf("ResolveOpenBaseURL(feishu with env override) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -22,6 +22,10 @@ const (
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliOpenBaseURL = "LARKSUITE_CLI_OPEN_BASE_URL"
|
||||
CliExtraHeaderName = "LARKSUITE_CLI_EXTRA_HEADER_NAME"
|
||||
CliExtraHeaderValue = "LARKSUITE_CLI_EXTRA_HEADER_VALUE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
CliCAPath = "LARKSUITE_CLI_CA_PATH"
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ type Stub struct {
|
||||
// matches after the first hit. Each match appends to CapturedBodies.
|
||||
Reusable bool
|
||||
|
||||
// Optional (optional): when true, Verify does not require this stub to be
|
||||
// matched. Useful for negative assertions via OnMatch.
|
||||
Optional bool
|
||||
|
||||
// CapturedHeaders records the request headers of the matched request.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
if s.Optional {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
continue
|
||||
|
||||
@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
|
||||
|
||||
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
|
||||
|
||||
## Public Domain Allowlists
|
||||
|
||||
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
|
||||
|
||||
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
|
||||
|
||||
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
|
||||
|
||||
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
|
||||
|
||||
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
|
||||
|
||||
## Semantic Blocker Policy
|
||||
|
||||
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:
|
||||
|
||||
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Exact test-only hostnames. Keep sorted.
|
||||
abc.feishu.cn
|
||||
attacker.example.com
|
||||
bytedance.feishu.cn
|
||||
cdn.feishu.cn
|
||||
evil.example.com
|
||||
example.feishu.cn
|
||||
example.larkoffice.com
|
||||
example.larksuite.com
|
||||
feishu.cn
|
||||
feishu.doubao.com
|
||||
gateway.docker.internal
|
||||
host.containers.internal
|
||||
host.docker.internal
|
||||
host.lima.internal
|
||||
lf3-static.bytednsdoc.com
|
||||
meetings.feishu.cn
|
||||
meetings.larksuite.com
|
||||
p3-lark-file.byteimg.com
|
||||
passport.feishu.cn
|
||||
sample.feishu.cn
|
||||
x.feishu.cn
|
||||
xxx.feishu.cn
|
||||
xxx.larksuite.com
|
||||
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# Exact public hostnames. Keep sorted.
|
||||
accounts.feishu.cn
|
||||
accounts.larksuite.com
|
||||
applink.feishu.cn
|
||||
applink.larksuite.com
|
||||
ark.ap-southeast.bytepluses.com
|
||||
github.com
|
||||
larkoffice.com
|
||||
lf-larkemail.bytetos.com
|
||||
mcp.feishu.cn
|
||||
mcp.larksuite.com
|
||||
open.feishu.cn
|
||||
open.larksuite.com
|
||||
registry.npmjs.org
|
||||
registry.npmmirror.com
|
||||
sf16-sg.tiktokcdn.com
|
||||
www.feishu.cn
|
||||
www.larksuite.com
|
||||
@@ -19,7 +19,7 @@ lint/
|
||||
├── lintapi/ # shared types every domain returns
|
||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||
└── errscontract/ # first domain: typed-error contract guards
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── runner.go
|
||||
├── typecheck.go
|
||||
├── violation.go # local type aliases to lintapi
|
||||
@@ -30,16 +30,19 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
└── scan_test.go
|
||||
└── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── unapproved.go # Go AST/type-aware hostname extraction
|
||||
├── policy.go # exact public/fixture allowlist validation
|
||||
├── diff.go # added-line attribution
|
||||
└── *_test.go
|
||||
```
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
`domaincontract` is a syntax-level regression guard for the resolver-owned
|
||||
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
|
||||
files it rejects:
|
||||
`domaincontract` contains two complementary Go source guards.
|
||||
|
||||
The resolver-ownership guard rejects:
|
||||
|
||||
- string literals containing a resolver-owned host FQDN
|
||||
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
|
||||
@@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by
|
||||
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
|
||||
the guard fails the lint module's tests.
|
||||
|
||||
This is not a general outbound-URL or data-flow analyzer. It does not inspect
|
||||
non-Go assets, hosts assembled from string fragments, SDK constructor option
|
||||
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
|
||||
remain the backstop for those cases.
|
||||
The approved-domain guard parses every Git-tracked Go file in full. In CI,
|
||||
unapproved-host findings are limited to values whose expressions intersect an
|
||||
added line; policy validation and unused-entry checks remain repository-wide.
|
||||
It rejects an exact hostname unless it is present in one of:
|
||||
|
||||
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
|
||||
- `internal/qualitygate/config/allowlists/public-domains.txt`, for production
|
||||
and test code; or
|
||||
- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for
|
||||
`*_test.go`, the repository-root `tests/`, and any `testdata/` (never
|
||||
`skills/`).
|
||||
|
||||
RFC 2606 example/test names are accepted independently of those lists. This
|
||||
includes the reserved `.test`, `.example`, `.invalid`, and `.localhost`
|
||||
namespaces and the exact names `example.com`, `example.net`, and `example.org`;
|
||||
they are safe placeholders rather than supported public endpoints.
|
||||
|
||||
High-confidence evidence is deliberately limited to static string expressions
|
||||
assigned to `host`, `hostname`, or `domain` semantic names (including common
|
||||
case/plural forms and collections), plus static strings whose entire value is
|
||||
an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals,
|
||||
escapes, compile-time concatenation, constant references, grouped declarations,
|
||||
multi-value assignments, and multiline expressions. Bare domain-shaped strings
|
||||
without hostname semantics are not blocked.
|
||||
|
||||
Sequence values are scanned individually. For a hostname-semantic map, a key or
|
||||
value is evidence only when it is the sole hostname-shaped side of that entry;
|
||||
ambiguous string-to-string entries are not guessed. Struct fields use Go type
|
||||
information so known non-network `Host` / `Domain` fields do not become hostname
|
||||
evidence merely because an enum or command category contains a dot.
|
||||
|
||||
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
|
||||
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
|
||||
hostnames, and have a current in-scope use. See
|
||||
`internal/qualitygate/config/README.md` for admission and approval rules.
|
||||
|
||||
This is not a general outbound-URL or cross-language data-flow analyzer. It does
|
||||
not inspect non-Go assets or dynamically constructed values.
|
||||
|
||||
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
|
||||
than hardcoding the host elsewhere.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# from the repo root (one level above lint/)
|
||||
# PR-scoped scan from the repo root (one level above lint/)
|
||||
go run -C lint . --changed-from <base-revision> ..
|
||||
|
||||
# Full inventory (also reports historical unapproved hostnames)
|
||||
go run -C lint . ..
|
||||
```
|
||||
|
||||
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
|
||||
|
||||
import "github.com/larksuite/cli/lint/lintapi"
|
||||
|
||||
// ScanRepo walks root and returns every violation produced by this
|
||||
// domain's checks. Domains MUST return []lintapi.Violation so the
|
||||
// top-level dispatcher can aggregate uniformly.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
// ScanRepoWithOptions walks root and returns every violation produced
|
||||
// by this domain's checks. Domains MUST return []lintapi.Violation so
|
||||
// the top-level dispatcher can aggregate uniformly.
|
||||
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
|
||||
```
|
||||
|
||||
3. Per-rule files are named `rule_<name>.go` with sibling
|
||||
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
|
||||
|
||||
```go
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepo},
|
||||
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
171
lint/domaincontract/diff.go
Normal file
171
lint/domaincontract/diff.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type addedLineRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
type changedGoPath struct {
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
|
||||
|
||||
func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) {
|
||||
if from == "" {
|
||||
return nil, nil
|
||||
}
|
||||
names, err := gitCommandOutput(
|
||||
root,
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--find-renames",
|
||||
"--diff-filter=ACMR",
|
||||
from+"...HEAD",
|
||||
"--",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list changed Go files: %w", err)
|
||||
}
|
||||
paths, err := parseChangedGoPaths(names)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse changed Go files: %w", err)
|
||||
}
|
||||
|
||||
out := map[string][]addedLineRange{}
|
||||
for _, path := range paths {
|
||||
args := []string{
|
||||
"diff",
|
||||
"--unified=0",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
"--diff-filter=ACMR",
|
||||
from + "...HEAD",
|
||||
"--",
|
||||
}
|
||||
if path.Old != path.New {
|
||||
args = append(args, path.Old)
|
||||
}
|
||||
args = append(args, path.New)
|
||||
patch, err := gitCommandOutput(root, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read diff for %s: %w", path.New, err)
|
||||
}
|
||||
ranges, err := parseAddedLineRanges(patch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse diff for %s: %w", path.New, err)
|
||||
}
|
||||
out[path.New] = ranges
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) {
|
||||
fields := bytes.Split(raw, []byte{0})
|
||||
var out []changedGoPath
|
||||
for i := 0; i < len(fields); {
|
||||
status := string(fields[i])
|
||||
i++
|
||||
if status == "" {
|
||||
break
|
||||
}
|
||||
if i >= len(fields) || len(fields[i]) == 0 {
|
||||
return nil, fmt.Errorf("truncated name-status record")
|
||||
}
|
||||
oldPath := filepath.ToSlash(string(fields[i]))
|
||||
i++
|
||||
newPath := oldPath
|
||||
if status[0] == 'R' || status[0] == 'C' {
|
||||
if i >= len(fields) || len(fields[i]) == 0 {
|
||||
return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath)
|
||||
}
|
||||
newPath = filepath.ToSlash(string(fields[i]))
|
||||
i++
|
||||
if status[0] == 'C' {
|
||||
// A copy introduces every destination line. Diff only the new
|
||||
// path so Git presents it as an added file rather than a
|
||||
// metadata-only copy with no added-line ranges.
|
||||
oldPath = newPath
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(newPath, ".go") {
|
||||
continue
|
||||
}
|
||||
out = append(out, changedGoPath{Old: oldPath, New: newPath})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) {
|
||||
var out []addedLineRange
|
||||
for _, raw := range bytes.Split(patch, []byte{'\n'}) {
|
||||
line := string(raw)
|
||||
if !strings.HasPrefix(line, "@@") {
|
||||
continue
|
||||
}
|
||||
match := unifiedHunkRE.FindStringSubmatch(line)
|
||||
if match == nil {
|
||||
return nil, fmt.Errorf("unsupported unified hunk header %q", line)
|
||||
}
|
||||
start, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse added start line in %q: %w", line, err)
|
||||
}
|
||||
count := 1
|
||||
if match[2] != "" {
|
||||
count, err = strconv.Atoi(match[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse added line count in %q: %w", line, err)
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, addedLineRange{Start: start, End: start + count - 1})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) {
|
||||
for _, r := range ranges {
|
||||
if start <= r.End && end >= r.Start {
|
||||
if start > r.Start {
|
||||
return start, true
|
||||
}
|
||||
return r.Start, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func gitCommandOutput(root string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||||
if stderr != "" {
|
||||
return nil, fmt.Errorf("%w: %s", err, stderr)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
96
lint/domaincontract/diff_test.go
Normal file
96
lint/domaincontract/diff_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseChangedGoPaths(t *testing.T) {
|
||||
raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00")
|
||||
got, err := parseChangedGoPaths(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []changedGoPath{
|
||||
{Old: "changed.go", New: "changed.go"},
|
||||
{Old: "old.go", New: "renamed.go"},
|
||||
{Old: "copied.go", New: "copied.go"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) {
|
||||
if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil {
|
||||
t.Fatal("expected truncated rename error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddedLineRanges(t *testing.T) {
|
||||
patch := []byte(`diff --git a/x.go b/x.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/x.go
|
||||
+++ b/x.go
|
||||
@@ -2,0 +3,2 @@
|
||||
+first
|
||||
+second
|
||||
@@ -10 +12 @@
|
||||
-old
|
||||
+new
|
||||
@@ -20 +21,0 @@
|
||||
-deleted
|
||||
`)
|
||||
got, err := parseAddedLineRanges(patch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("ranges = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ranges = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) {
|
||||
if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil {
|
||||
t.Fatal("expected unsupported hunk error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAddedLineInSpan(t *testing.T) {
|
||||
ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}}
|
||||
tests := []struct {
|
||||
start, end int
|
||||
line int
|
||||
ok bool
|
||||
}{
|
||||
{start: 1, end: 4, ok: false},
|
||||
{start: 4, end: 6, line: 5, ok: true},
|
||||
{start: 6, end: 9, line: 6, ok: true},
|
||||
{start: 8, end: 12, line: 10, ok: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end)
|
||||
if line != tc.line || ok != tc.ok {
|
||||
t.Errorf(
|
||||
"firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)",
|
||||
tc.start,
|
||||
tc.end,
|
||||
line,
|
||||
ok,
|
||||
tc.line,
|
||||
tc.ok,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
126
lint/domaincontract/policy.go
Normal file
126
lint/domaincontract/policy.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt"
|
||||
fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt"
|
||||
)
|
||||
|
||||
type domainPolicyEntry struct {
|
||||
Host string
|
||||
File string
|
||||
Line int
|
||||
}
|
||||
|
||||
type domainPolicy struct {
|
||||
Public map[string]domainPolicyEntry
|
||||
Fixtures map[string]domainPolicyEntry
|
||||
}
|
||||
|
||||
// isReservedExampleHostname recognizes only names reserved by RFC 2606 for
|
||||
// examples, testing, invalid-name examples, and localhost use. These names are
|
||||
// safe source placeholders and are policy exceptions, not supported public
|
||||
// endpoints.
|
||||
func isReservedExampleHostname(host string) bool {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
switch host {
|
||||
case "example.com", "example.net", "example.org":
|
||||
return true
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
switch labels[len(labels)-1] {
|
||||
case "test", "example", "invalid", "localhost":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func loadDomainPolicy(root string) (domainPolicy, error) {
|
||||
public, err := loadDomainList(root, publicDomainsPath)
|
||||
if err != nil {
|
||||
return domainPolicy{}, err
|
||||
}
|
||||
fixtures, err := loadDomainList(root, fixtureDomainsPath)
|
||||
if err != nil {
|
||||
return domainPolicy{}, err
|
||||
}
|
||||
for host, entry := range fixtures {
|
||||
if publicEntry, ok := public[host]; ok {
|
||||
return domainPolicy{}, fmt.Errorf(
|
||||
"%s:%d: hostname %q is already listed at %s:%d",
|
||||
entry.File, entry.Line, host, publicEntry.File, publicEntry.Line,
|
||||
)
|
||||
}
|
||||
}
|
||||
return domainPolicy{Public: public, Fixtures: fixtures}, nil
|
||||
}
|
||||
|
||||
func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) {
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
entries := map[string]domainPolicyEntry{}
|
||||
var previous string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for line := 1; scanner.Scan(); line++ {
|
||||
host := strings.TrimSpace(scanner.Text())
|
||||
if host == "" || strings.HasPrefix(host, "#") {
|
||||
continue
|
||||
}
|
||||
if host != strings.ToLower(host) {
|
||||
return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host)
|
||||
}
|
||||
if err := validatePolicyHostname(host); err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", rel, line, err)
|
||||
}
|
||||
if previous != "" && host <= previous {
|
||||
return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host)
|
||||
}
|
||||
entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line}
|
||||
previous = host
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%s: domain list must not be empty", rel)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validatePolicyHostname(host string) error {
|
||||
if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") {
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
for _, r := range label {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
}
|
||||
if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") {
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
120
lint/domaincontract/policy_test.go
Normal file
120
lint/domaincontract/policy_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDomainPolicy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n")
|
||||
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
|
||||
|
||||
policy, err := loadDomainPolicy(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(policy.Public) != 2 || len(policy.Fixtures) != 1 {
|
||||
t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures))
|
||||
}
|
||||
if policy.Public["api.example.com"].Line != 2 {
|
||||
t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
public string
|
||||
fixtures string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uppercase",
|
||||
public: "API.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "must be lowercase",
|
||||
},
|
||||
{
|
||||
name: "unsorted",
|
||||
public: "www.example.com\napi.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "unique and sorted",
|
||||
},
|
||||
{
|
||||
name: "duplicate",
|
||||
public: "api.example.com\napi.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "unique and sorted",
|
||||
},
|
||||
{
|
||||
name: "wildcard",
|
||||
public: "*.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "scheme",
|
||||
public: "https://example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
public: "api.example.com/v1\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
public: "api.example.com:443\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "cross-list duplicate",
|
||||
public: "api.example.com\n",
|
||||
fixtures: "api.example.com\n",
|
||||
want: "already listed",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, publicDomainsPath, tc.public)
|
||||
writeFile(t, root, fixtureDomainsPath, tc.fixtures)
|
||||
_, err := loadDomainPolicy(root)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedExampleHostname(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"example.com",
|
||||
"example.net",
|
||||
"example.org",
|
||||
"example.test",
|
||||
"docs.example",
|
||||
"missing.invalid",
|
||||
"service.localhost",
|
||||
} {
|
||||
if !isReservedExampleHostname(host) {
|
||||
t.Errorf("%q should be a reserved example hostname", host)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{
|
||||
"attacker.example.com",
|
||||
"example.dev",
|
||||
"private.corp.internal",
|
||||
} {
|
||||
if isReservedExampleHostname(host) {
|
||||
t.Errorf("%q must still require policy approval", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package domaincontract guards the Go CLI against direct reuse of the current
|
||||
// resolver-owned host FQDNs outside core.ResolveEndpoints.
|
||||
// Package domaincontract guards resolver ownership and rejects newly introduced
|
||||
// static Go hostnames that are not covered by the repository domain policy.
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanRepo walks production .go files under root and flags string literals
|
||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
||||
// _test.go files are not scanned.
|
||||
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
|
||||
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
|
||||
// historical unapproved domains are not attributed to an unrelated change.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
||||
return ScanRepoWithOptions(root, ScanOptions{})
|
||||
}
|
||||
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) {
|
||||
out, err := scanHardcodedEndpoints(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainViolations, err := scanUnapprovedDomains(root, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, domainViolations...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
|
||||
911
lint/domaincontract/unapproved.go
Normal file
911
lint/domaincontract/unapproved.go
Normal file
@@ -0,0 +1,911 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/constant"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
const (
|
||||
unapprovedDomainRule = "unapproved-domain"
|
||||
unusedDomainRule = "domain-allowlist-unused"
|
||||
incompleteDomainRule = "domain-scan-incomplete"
|
||||
)
|
||||
|
||||
type typedGoFile struct {
|
||||
File *ast.File
|
||||
Fset *token.FileSet
|
||||
Info *types.Info
|
||||
}
|
||||
|
||||
type domainEvidence struct {
|
||||
Host string
|
||||
Kind string
|
||||
Expr ast.Expr
|
||||
}
|
||||
|
||||
type evidenceKey struct {
|
||||
Host string
|
||||
Start, End token.Pos
|
||||
}
|
||||
|
||||
type fileDomainScan struct {
|
||||
File *ast.File
|
||||
Fset *token.FileSet
|
||||
Info *types.Info
|
||||
Evidence []domainEvidence
|
||||
TypeInfoRequired []ast.Expr
|
||||
seen map[evidenceKey]bool
|
||||
parents map[ast.Node]ast.Node
|
||||
}
|
||||
|
||||
type collectionCompositeKind uint8
|
||||
|
||||
const (
|
||||
notCollectionComposite collectionCompositeKind = iota
|
||||
sequenceComposite
|
||||
mapComposite
|
||||
)
|
||||
|
||||
type hostnameFieldID struct {
|
||||
Type string
|
||||
Field string
|
||||
}
|
||||
|
||||
var nonNetworkHostnameFields = map[hostnameFieldID]bool{
|
||||
{Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true,
|
||||
{Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true,
|
||||
}
|
||||
|
||||
func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve repository root: %w", err)
|
||||
}
|
||||
publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath))
|
||||
if _, err := os.Stat(publicPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("domain policy unavailable: %w", err)
|
||||
}
|
||||
policy, err := loadDomainPolicy(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
added, err := changedGoLineRanges(root, opts.ChangedFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed, typeLoadErr := loadTypedGoFiles(root)
|
||||
goFiles, err := trackedGoFiles(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
observedPublic := map[string]bool{}
|
||||
observedFixtures := map[string]bool{}
|
||||
inventoryComplete := typeLoadErr == nil
|
||||
var out []lintapi.Violation
|
||||
parseFailureReported := false
|
||||
typeInfoGapReported := false
|
||||
for _, rel := range goFiles {
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
parsedFset := token.NewFileSet()
|
||||
parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0)
|
||||
if parseErr != nil {
|
||||
inventoryComplete = false
|
||||
if opts.ChangedFrom == "" {
|
||||
out = append(out, incompleteDomainViolation(rel, parseErr))
|
||||
parseFailureReported = true
|
||||
} else if _, changed := added[rel]; changed {
|
||||
out = append(out, incompleteDomainViolation(rel, parseErr))
|
||||
parseFailureReported = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
tf, ok := typed[filepath.Clean(path)]
|
||||
if !ok {
|
||||
tf = typedGoFile{File: parsedFile, Fset: parsedFset}
|
||||
}
|
||||
|
||||
scan := newFileDomainScan(tf)
|
||||
scan.collectSemanticEvidence()
|
||||
scan.collectAbsoluteURLEvidence()
|
||||
if len(scan.TypeInfoRequired) > 0 {
|
||||
// Inventory completeness is a property of the whole HEAD. Whether
|
||||
// this PR owns an incomplete-scan diagnostic is decided separately
|
||||
// by the added-line intersection below.
|
||||
inventoryComplete = false
|
||||
}
|
||||
for _, expr := range scan.TypeInfoRequired {
|
||||
start := tf.Fset.Position(expr.Pos()).Line
|
||||
end := tf.Fset.Position(expr.End()).Line
|
||||
line := start
|
||||
if opts.ChangedFrom != "" {
|
||||
var intersects bool
|
||||
line, intersects = firstAddedLineInSpan(added[rel], start, end)
|
||||
if !intersects {
|
||||
continue
|
||||
}
|
||||
}
|
||||
typeInfoGapReported = true
|
||||
out = append(out, incompleteDomainViolationAt(
|
||||
rel,
|
||||
line,
|
||||
fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"),
|
||||
))
|
||||
break
|
||||
}
|
||||
fixture := isDomainFixturePath(rel)
|
||||
// The detector's own policy literals and contract corpus may be
|
||||
// scanned, but they cannot justify keeping an allowlist entry.
|
||||
policyOwner := strings.HasPrefix(rel, "lint/domaincontract/")
|
||||
for _, evidence := range scan.Evidence {
|
||||
if isReservedExampleHostname(evidence.Host) {
|
||||
continue
|
||||
}
|
||||
if _, ok := policy.Public[evidence.Host]; ok {
|
||||
if !fixture && !policyOwner {
|
||||
observedPublic[evidence.Host] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := policy.Fixtures[evidence.Host]; ok && fixture {
|
||||
if !policyOwner {
|
||||
observedFixtures[evidence.Host] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
start := tf.Fset.Position(evidence.Expr.Pos()).Line
|
||||
end := tf.Fset.Position(evidence.Expr.End()).Line
|
||||
line := start
|
||||
if opts.ChangedFrom != "" {
|
||||
var intersects bool
|
||||
line, intersects = firstAddedLineInSpan(added[rel], start, end)
|
||||
if !intersects {
|
||||
continue
|
||||
}
|
||||
}
|
||||
suggestion := "remove the hostname or replace it with an approved public endpoint; " +
|
||||
"public allowlist additions require evidence and CODEOWNER approval"
|
||||
if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture {
|
||||
suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " +
|
||||
"fixture entries are not approved for production Go code or skills"
|
||||
}
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: unapprovedDomainRule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: rel,
|
||||
Line: line,
|
||||
Message: fmt.Sprintf(
|
||||
"unapproved hostname %q found in %s",
|
||||
evidence.Host,
|
||||
evidence.Kind,
|
||||
),
|
||||
Suggestion: suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A syntax error is also surfaced by go/packages. Prefer the file-specific
|
||||
// parse diagnostic when one was already reported; otherwise make a
|
||||
// repository-wide type-loading failure explicit instead of silently
|
||||
// continuing without the type information required by field evidence.
|
||||
if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported {
|
||||
out = append(out, incompleteDomainViolation("go.mod", typeLoadErr))
|
||||
}
|
||||
|
||||
if inventoryComplete {
|
||||
for host, entry := range policy.Public {
|
||||
if !observedPublic[host] {
|
||||
out = append(out, unusedDomainViolation(entry))
|
||||
}
|
||||
}
|
||||
for host, entry := range policy.Fixtures {
|
||||
if !observedFixtures[host] {
|
||||
out = append(out, unusedDomainViolation(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func trackedGoFiles(root string) ([]string, error) {
|
||||
out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tracked Go files: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, raw := range strings.Split(string(out), "\x00") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
rel := filepath.ToSlash(raw)
|
||||
if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") {
|
||||
continue
|
||||
}
|
||||
files = append(files, rel)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func loadTypedGoFiles(root string) (map[string]typedGoFile, error) {
|
||||
moduleDirs, err := trackedGoModuleDirs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]typedGoFile{}
|
||||
var firstLoadErr error
|
||||
var loadErrCount int
|
||||
for _, moduleDir := range moduleDirs {
|
||||
moduleRoot := root
|
||||
if moduleDir != "." {
|
||||
moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir))
|
||||
}
|
||||
files, err := loadTypedGoModule(moduleRoot)
|
||||
for path, file := range files {
|
||||
out[path] = file
|
||||
}
|
||||
if err != nil {
|
||||
loadErrCount++
|
||||
if firstLoadErr == nil {
|
||||
firstLoadErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
if loadErrCount == 1 {
|
||||
return out, firstLoadErr
|
||||
}
|
||||
if loadErrCount > 1 {
|
||||
return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func trackedGoModuleDirs(root string) ([]string, error) {
|
||||
raw, err := gitCommandOutput(root, "ls-files", "-z")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tracked Go modules: %w", err)
|
||||
}
|
||||
var dirs []string
|
||||
for _, path := range strings.Split(string(raw), "\x00") {
|
||||
path = filepath.ToSlash(path)
|
||||
if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.ToSlash(filepath.Dir(path))
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) {
|
||||
fset := token.NewFileSet()
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName |
|
||||
packages.NeedFiles |
|
||||
packages.NeedCompiledGoFiles |
|
||||
packages.NeedImports |
|
||||
packages.NeedDeps |
|
||||
packages.NeedTypes |
|
||||
packages.NeedSyntax |
|
||||
packages.NeedTypesInfo,
|
||||
Dir: moduleRoot,
|
||||
Fset: fset,
|
||||
Tests: true,
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Go type information: %w", err)
|
||||
}
|
||||
out := map[string]typedGoFile{}
|
||||
var firstPackageErr string
|
||||
var packageErrCount int
|
||||
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
|
||||
if pkg == nil {
|
||||
return
|
||||
}
|
||||
for _, pkgErr := range pkg.Errors {
|
||||
packageErrCount++
|
||||
if firstPackageErr == "" {
|
||||
firstPackageErr = pkgErr.Error()
|
||||
}
|
||||
}
|
||||
if pkg.TypesInfo == nil || pkg.Fset == nil {
|
||||
return
|
||||
}
|
||||
for i, file := range pkg.Syntax {
|
||||
if i >= len(pkg.CompiledGoFiles) {
|
||||
break
|
||||
}
|
||||
path := filepath.Clean(pkg.CompiledGoFiles[i])
|
||||
if _, exists := out[path]; exists {
|
||||
continue
|
||||
}
|
||||
out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo}
|
||||
}
|
||||
})
|
||||
if packageErrCount == 1 {
|
||||
return out, fmt.Errorf("load Go type information: %s", firstPackageErr)
|
||||
}
|
||||
if packageErrCount > 1 {
|
||||
return out, fmt.Errorf(
|
||||
"load Go type information: %s (and %d more package errors)",
|
||||
firstPackageErr,
|
||||
packageErrCount-1,
|
||||
)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newFileDomainScan(file typedGoFile) *fileDomainScan {
|
||||
return &fileDomainScan{
|
||||
File: file.File,
|
||||
Fset: file.Fset,
|
||||
Info: file.Info,
|
||||
seen: map[evidenceKey]bool{},
|
||||
parents: astParentMap(file.File),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) collectSemanticEvidence() {
|
||||
ast.Inspect(s.File, func(node ast.Node) bool {
|
||||
switch n := node.(type) {
|
||||
case *ast.AssignStmt:
|
||||
if len(n.Lhs) != len(n.Rhs) {
|
||||
return true
|
||||
}
|
||||
for i, lhs := range n.Lhs {
|
||||
if s.Info == nil &&
|
||||
potentialHostnameSelectorTarget(lhs) &&
|
||||
s.hasStaticBareHostnameValue(n.Rhs[i]) {
|
||||
s.requireTypeInfo(n.Rhs[i])
|
||||
}
|
||||
if index, ok := stripParens(lhs).(*ast.IndexExpr); ok {
|
||||
switch {
|
||||
case s.isHostnameTarget(index.X):
|
||||
s.addMapPair(index.Index, n.Rhs[i])
|
||||
case s.isHostnameMapKey(index.Index):
|
||||
s.addHostValue(n.Rhs[i], "host assignment")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.isHostnameTarget(lhs) {
|
||||
s.addHostValue(n.Rhs[i], "host assignment")
|
||||
}
|
||||
}
|
||||
case *ast.ValueSpec:
|
||||
if len(n.Names) != len(n.Values) {
|
||||
return true
|
||||
}
|
||||
for i, name := range n.Names {
|
||||
if isHostnameSemanticName(name.Name) {
|
||||
s.addHostValue(n.Values[i], "host assignment")
|
||||
}
|
||||
}
|
||||
case *ast.KeyValueExpr:
|
||||
if s.Info == nil && s.keyValueNeedsTypeInfo(n) {
|
||||
s.requireTypeInfo(n.Value)
|
||||
}
|
||||
if s.isHostnameKeyValue(n) {
|
||||
s.addHostValue(n.Value, "host assignment")
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) {
|
||||
for _, existing := range s.TypeInfoRequired {
|
||||
if existing.Pos() == expr.Pos() && existing.End() == expr.End() {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.TypeInfoRequired = append(s.TypeInfoRequired, expr)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool {
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
host, ok := semanticHostname(value)
|
||||
return ok && !isReservedExampleHostname(host)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool {
|
||||
composite, ok := s.parents[pair].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, explicitMap := composite.Type.(*ast.MapType); explicitMap {
|
||||
return false
|
||||
}
|
||||
key, ok := pair.Key.(*ast.Ident)
|
||||
return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value)
|
||||
}
|
||||
|
||||
func potentialHostnameSelectorTarget(expr ast.Expr) bool {
|
||||
switch n := stripParens(expr).(type) {
|
||||
case *ast.SelectorExpr:
|
||||
return isHostnameSemanticName(n.Sel.Name)
|
||||
case *ast.StarExpr:
|
||||
return potentialHostnameSelectorTarget(n.X)
|
||||
case *ast.IndexExpr:
|
||||
return potentialHostnameSelectorTarget(n.X)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) collectAbsoluteURLEvidence() {
|
||||
ast.Inspect(s.File, func(node ast.Node) bool {
|
||||
expr, ok := node.(ast.Expr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil {
|
||||
// A declaration name may carry the constant value in types.Info,
|
||||
// but it is not a second source expression.
|
||||
return true
|
||||
}
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if s.hasStaticStringContainer(expr) {
|
||||
return true
|
||||
}
|
||||
host, ok := absoluteURLHostname(value)
|
||||
if ok {
|
||||
s.addEvidence(host, "absolute URL", expr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool {
|
||||
parent, ok := s.parents[expr].(ast.Expr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch parent.(type) {
|
||||
case *ast.BinaryExpr, *ast.ParenExpr:
|
||||
_, ok := staticStringValue(parent, s.Info, nil)
|
||||
return ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) {
|
||||
expr = stripParens(expr)
|
||||
if composite, ok := expr.(*ast.CompositeLit); ok {
|
||||
switch s.collectionCompositeKind(composite) {
|
||||
case sequenceComposite:
|
||||
for _, element := range composite.Elts {
|
||||
if valueExpr, ok := element.(ast.Expr); ok {
|
||||
s.addHostValue(valueExpr, "host collection")
|
||||
}
|
||||
}
|
||||
case mapComposite:
|
||||
for _, element := range composite.Elts {
|
||||
pair, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyExpr, ok := pair.Key.(ast.Expr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s.addMapPair(keyExpr, pair.Value)
|
||||
}
|
||||
default:
|
||||
if s.Info == nil {
|
||||
s.requireTypeInfoForUnclassifiedCollection(composite)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if evidence, ok := s.hostnameEvidence(expr, kind); ok {
|
||||
s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) {
|
||||
for _, element := range composite.Elts {
|
||||
if pair, ok := element.(*ast.KeyValueExpr); ok {
|
||||
keyExpr, ok := pair.Key.(ast.Expr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyIsHost := s.hasStaticBareHostnameValue(keyExpr)
|
||||
valueIsHost := s.hasStaticBareHostnameValue(pair.Value)
|
||||
if keyIsHost == valueIsHost {
|
||||
continue
|
||||
}
|
||||
if keyIsHost {
|
||||
s.requireTypeInfo(keyExpr)
|
||||
} else {
|
||||
s.requireTypeInfo(pair.Value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
valueExpr, ok := element.(ast.Expr)
|
||||
if ok && s.hasStaticBareHostnameValue(valueExpr) {
|
||||
s.requireTypeInfo(valueExpr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addMapPair reports a map side only when it is the sole hostname-shaped
|
||||
// static value. A semantic map name does not establish whether a string map
|
||||
// is hostname->metadata or alias->hostname, so reporting both sides would turn
|
||||
// filenames such as client.pem into blocking hostname evidence.
|
||||
func (s *fileDomainScan) addMapPair(key, value ast.Expr) {
|
||||
keyEvidence, keyOK := s.hostnameEvidence(key, "host collection")
|
||||
valueEvidence, valueOK := s.hostnameEvidence(value, "host collection")
|
||||
if keyOK == valueOK {
|
||||
return
|
||||
}
|
||||
if keyOK {
|
||||
s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr)
|
||||
return
|
||||
}
|
||||
s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) {
|
||||
expr = stripParens(expr)
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
if !ok {
|
||||
return domainEvidence{}, false
|
||||
}
|
||||
if host, ok := absoluteURLHostname(value); ok {
|
||||
return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true
|
||||
}
|
||||
if host, ok := semanticHostname(value); ok {
|
||||
return domainEvidence{Host: host, Kind: kind, Expr: expr}, true
|
||||
}
|
||||
return domainEvidence{}, false
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind {
|
||||
if s.Info != nil {
|
||||
if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil {
|
||||
switch tv.Type.Underlying().(type) {
|
||||
case *types.Array, *types.Slice:
|
||||
return sequenceComposite
|
||||
case *types.Map:
|
||||
return mapComposite
|
||||
}
|
||||
}
|
||||
}
|
||||
switch expr.Type.(type) {
|
||||
case *ast.ArrayType:
|
||||
return sequenceComposite
|
||||
case *ast.MapType:
|
||||
return mapComposite
|
||||
default:
|
||||
return notCollectionComposite
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) {
|
||||
key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()}
|
||||
if s.seen[key] {
|
||||
return
|
||||
}
|
||||
s.seen[key] = true
|
||||
s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr})
|
||||
}
|
||||
|
||||
func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) {
|
||||
if info != nil {
|
||||
if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String {
|
||||
return constant.StringVal(tv.Value), true
|
||||
}
|
||||
}
|
||||
switch n := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if n.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(n.Value)
|
||||
return value, err == nil
|
||||
case *ast.ParenExpr:
|
||||
return staticStringValue(n.X, info, seen)
|
||||
case *ast.BinaryExpr:
|
||||
if n.Op != token.ADD {
|
||||
return "", false
|
||||
}
|
||||
left, ok := staticStringValue(n.X, info, seen)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
right, ok := staticStringValue(n.Y, info, seen)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return left + right, true
|
||||
case *ast.Ident:
|
||||
if info != nil {
|
||||
if obj := info.ObjectOf(n); obj != nil {
|
||||
if c, ok := obj.(*types.Const); ok {
|
||||
if c.Val().Kind() == constant.String {
|
||||
return constant.StringVal(c.Val()), true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Obj == nil || n.Obj.Kind != ast.Con {
|
||||
return "", false
|
||||
}
|
||||
if seen == nil {
|
||||
seen = map[*ast.Object]bool{}
|
||||
}
|
||||
if seen[n.Obj] {
|
||||
return "", false
|
||||
}
|
||||
seen[n.Obj] = true
|
||||
defer delete(seen, n.Obj)
|
||||
spec, ok := n.Obj.Decl.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
for i, name := range spec.Names {
|
||||
if name.Name == n.Name && i < len(spec.Values) {
|
||||
return staticStringValue(spec.Values[i], info, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func absoluteURLHostname(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http", "https", "ws", "wss":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
return normalizeCandidateHostname(parsed.Hostname())
|
||||
}
|
||||
|
||||
func semanticHostname(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse("//" + value)
|
||||
if err != nil || parsed.Host == "" || parsed.Path != "" {
|
||||
return "", false
|
||||
}
|
||||
return normalizeCandidateHostname(parsed.Hostname())
|
||||
}
|
||||
|
||||
func normalizeCandidateHostname(host string) (string, bool) {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil {
|
||||
return "", false
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||
continue
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return host, true
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool {
|
||||
switch n := stripParens(expr).(type) {
|
||||
case *ast.Ident:
|
||||
return isHostnameSemanticName(n.Name)
|
||||
case *ast.SelectorExpr:
|
||||
return s.isHostnameSelector(n)
|
||||
case *ast.StarExpr:
|
||||
return s.isHostnameTarget(n.X)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool {
|
||||
composite, ok := s.parents[pair].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch s.collectionCompositeKind(composite) {
|
||||
case mapComposite:
|
||||
key, ok := pair.Key.(ast.Expr)
|
||||
return ok && s.isHostnameMapKey(key)
|
||||
case notCollectionComposite:
|
||||
ident, ok := pair.Key.(*ast.Ident)
|
||||
return ok && s.isHostnameStructField(composite, ident.Name)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool {
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
return ok && isHostnameSemanticName(value)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool {
|
||||
if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) {
|
||||
return false
|
||||
}
|
||||
selection := s.Info.Selections[selector]
|
||||
if selection == nil || selection.Kind() != types.FieldVal {
|
||||
return false
|
||||
}
|
||||
return !nonNetworkHostnameFields[hostnameFieldID{
|
||||
Type: namedTypeID(selection.Recv()),
|
||||
Field: selector.Sel.Name,
|
||||
}]
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool {
|
||||
if s.Info == nil || !isHostnameSemanticName(field) {
|
||||
return false
|
||||
}
|
||||
typeID := namedTypeID(s.Info.TypeOf(composite))
|
||||
if typeID == "" {
|
||||
return false
|
||||
}
|
||||
return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}]
|
||||
}
|
||||
|
||||
func namedTypeID(typ types.Type) string {
|
||||
for {
|
||||
switch t := typ.(type) {
|
||||
case *types.Pointer:
|
||||
typ = t.Elem()
|
||||
case *types.Named:
|
||||
obj := t.Obj()
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
return ""
|
||||
}
|
||||
return obj.Pkg().Path() + "." + obj.Name()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isHostnameSemanticName(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
switch lower {
|
||||
case "host", "hosts", "hostname", "hostnames", "domain", "domains":
|
||||
return true
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy",
|
||||
} {
|
||||
if i := strings.Index(name, marker); i >= 0 {
|
||||
end := i + len(marker)
|
||||
if end < len(name) && unicode.IsUpper(rune(name[end])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy",
|
||||
} {
|
||||
if strings.HasPrefix(name, prefix) &&
|
||||
len(name) > len(prefix) &&
|
||||
unicode.IsUpper(rune(name[len(prefix)])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if i := strings.LastIndexAny(name, "_-"); i >= 0 {
|
||||
return isHostnameSemanticName(name[i+1:])
|
||||
}
|
||||
for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} {
|
||||
if strings.HasSuffix(name, suffix) && len(name) > len(suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stripParens(expr ast.Expr) ast.Expr {
|
||||
for {
|
||||
paren, ok := expr.(*ast.ParenExpr)
|
||||
if !ok {
|
||||
return expr
|
||||
}
|
||||
expr = paren.X
|
||||
}
|
||||
}
|
||||
|
||||
func astParentMap(root ast.Node) map[ast.Node]ast.Node {
|
||||
parents := map[ast.Node]ast.Node{}
|
||||
var stack []ast.Node
|
||||
ast.Inspect(root, func(node ast.Node) bool {
|
||||
if node == nil {
|
||||
stack = stack[:len(stack)-1]
|
||||
return false
|
||||
}
|
||||
if len(stack) > 0 {
|
||||
parents[node] = stack[len(stack)-1]
|
||||
}
|
||||
stack = append(stack, node)
|
||||
return true
|
||||
})
|
||||
return parents
|
||||
}
|
||||
|
||||
func isDomainFixturePath(rel string) bool {
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(rel, "skills/") {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.Split(rel, "/") {
|
||||
if part == "testdata" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation {
|
||||
return lintapi.Violation{
|
||||
Rule: unusedDomainRule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: entry.File,
|
||||
Line: entry.Line,
|
||||
Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host),
|
||||
Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference",
|
||||
}
|
||||
}
|
||||
|
||||
func incompleteDomainViolation(file string, err error) lintapi.Violation {
|
||||
return incompleteDomainViolationAt(file, 1, err)
|
||||
}
|
||||
|
||||
func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation {
|
||||
return lintapi.Violation{
|
||||
Rule: incompleteDomainRule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: file,
|
||||
Line: line,
|
||||
Message: "domain scan incomplete: " + err.Error(),
|
||||
Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete",
|
||||
}
|
||||
}
|
||||
462
lint/domaincontract/unapproved_repo_test.go
Normal file
462
lint/domaincontract/unapproved_repo_test.go
Normal file
@@ -0,0 +1,462 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
func gitTestCommand(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func setupDomainDiffRepo(t *testing.T, target string) (root, base string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n")
|
||||
writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n")
|
||||
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
|
||||
writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n")
|
||||
writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n")
|
||||
writeFile(t, root, "target.go", target)
|
||||
|
||||
gitTestCommand(t, root, "init", "-q")
|
||||
gitTestCommand(t, root, "config", "user.name", "Domain Contract Test")
|
||||
gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com")
|
||||
gitTestCommand(t, root, "add", ".")
|
||||
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base")
|
||||
return root, gitTestCommand(t, root, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func commitDomainDiff(t *testing.T, root, message string) {
|
||||
t.Helper()
|
||||
gitTestCommand(t, root, "add", "-A")
|
||||
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message)
|
||||
}
|
||||
|
||||
func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation {
|
||||
var out []lintapi.Violation
|
||||
for _, v := range vs {
|
||||
if v.Rule == rule {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation {
|
||||
t.Helper()
|
||||
vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
func TestUnapprovedDomainDiffContract(t *testing.T) {
|
||||
t.Run("new PR 1975 case", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n")
|
||||
commitDomainDiff(t, root, "add internal host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") {
|
||||
t.Fatalf("violations = %+v, want PR 1975 hostname", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hostname field in nested Go module", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n")
|
||||
writeFile(t, root, "nested/target.go",
|
||||
"package nested\n\ntype Config struct{ Host string }\n\n"+
|
||||
"var config = Config{Host: \"private.corp.internal\"}\n")
|
||||
commitDomainDiff(t, root, "add nested module hostname")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, unapprovedDomainRule)
|
||||
if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" ||
|
||||
!strings.Contains(got[0].Message, "private.corp.internal") {
|
||||
t.Fatalf("violations = %+v, want nested-module hostname rejection", got)
|
||||
}
|
||||
if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 {
|
||||
t.Fatalf("nested module must have complete type information: %+v", incomplete)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded field reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type Config struct{ Host string }\n\n"+
|
||||
"var config = Config{Host: \"private.corp.internal\"}\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname field")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
|
||||
t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got)
|
||||
}
|
||||
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||
t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type Config struct{ Host string }\n\n"+
|
||||
"func configure(config *Config) { config.Host = \"private.corp.internal\" }\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname selector")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
|
||||
t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type HostList []string\n\n"+
|
||||
"var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname slice")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
|
||||
t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got)
|
||||
}
|
||||
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||
t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type HostSet map[string]struct{}\n\n"+
|
||||
"var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname map")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
|
||||
t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got)
|
||||
}
|
||||
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||
t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n")
|
||||
commitDomainDiff(t, root, "add excluded unrelated code")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new element in existing collection", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n")
|
||||
commitDomainDiff(t, root, "add collection host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
|
||||
t.Fatalf("violations = %+v, want attacker.zip", got)
|
||||
}
|
||||
if got[0].Line != 5 {
|
||||
t.Fatalf("violation line = %d, want 5", got[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiline expression changed segment", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n")
|
||||
commitDomainDiff(t, root, "change concatenated host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
|
||||
t.Fatalf("violations = %+v, want private.corp.internal", got)
|
||||
}
|
||||
if got[0].Line != 4 {
|
||||
t.Fatalf("violation line = %d, want changed line 4", got[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrelated change beside historical hostname", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n")
|
||||
commitDomainDiff(t, root, "add unrelated value")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected historical-domain violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historical hostname expression changed", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n")
|
||||
commitDomainDiff(t, root, "change historical host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") {
|
||||
t.Fatalf("violations = %+v, want replacement.private.internal", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new assignment references existing constant", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nconst existingConst = \"private.corp.internal\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n")
|
||||
commitDomainDiff(t, root, "use existing hostname constant")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
|
||||
t.Fatalf("violations = %+v, want private.corp.internal", got)
|
||||
}
|
||||
if got[0].Line != 4 {
|
||||
t.Fatalf("violation line = %d, want 4", got[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlisted hostname", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n")
|
||||
commitDomainDiff(t, root, "add public host")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected public-domain violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reserved example URL", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n")
|
||||
commitDomainDiff(t, root, "add safe example URL")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected reserved-example violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) {
|
||||
root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\nplatform.example.com\npublic.example.com\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type Config struct{ Host string }\n\n"+
|
||||
"var config = Config{Host: \"platform.example.com\"}\n")
|
||||
commitDomainDiff(t, root, "add historical platform hostname")
|
||||
base := gitTestCommand(t, root, "rev-parse", "HEAD")
|
||||
|
||||
writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n")
|
||||
commitDomainDiff(t, root, "change unrelated code")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 {
|
||||
t.Fatalf("historical type gap must not be attributed to this change: %+v", got)
|
||||
}
|
||||
if got := violationsForRule(all, unusedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist does not approve subdomains", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n")
|
||||
commitDomainDiff(t, root, "add unapproved public subdomain")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") {
|
||||
t.Fatalf("violations = %+v, want evil.public.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi assignment pairs names and values", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\nopen.larksuite.com\npublic.example.com\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n")
|
||||
commitDomainDiff(t, root, "add multiple hosts")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
|
||||
t.Fatalf("violations = %+v, want only attacker.zip", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IDN hostname is rejected", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n")
|
||||
commitDomainDiff(t, root, "add IDN hostname")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") {
|
||||
t.Fatalf("violations = %+v, want IDN hostname", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture limited to test files", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use fixture in production")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
|
||||
t.Fatalf("violations = %+v, want production fixture rejection", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Suggestion, "fixture-only hostname") ||
|
||||
strings.Contains(got[0].Suggestion, "public allowlist") {
|
||||
t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture accepted in test file", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "new_target_test.go",
|
||||
"package sample\n\nvar BackupHost = \"fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use fixture in test")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected fixture-domain violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "new_target_test.go",
|
||||
"package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use unapproved fixture subdomain")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") {
|
||||
t.Fatalf("violations = %+v, want exact fixture match", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture rejected in skills", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "skills/example/example_test.go",
|
||||
"package example\n\nvar BackupHost = \"fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use fixture in skill")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
|
||||
t.Fatalf("violations = %+v, want skill fixture rejection", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pure rename", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||
gitTestCommand(t, root, "mv", "target.go", "renamed.go")
|
||||
commitDomainDiff(t, root, "rename file")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected rename violation: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) {
|
||||
t.Run("unused policy entry", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\npublic.example.com\nunused.example.com\n")
|
||||
commitDomainDiff(t, root, "add unused policy")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") {
|
||||
t.Fatalf("violations = %+v, want unused.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public entry used only by fixture", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\npublic.example.com\ntest-only.example.com\n")
|
||||
writeFile(t, root, "public_only_test.go",
|
||||
"package sample\n\nvar BackupHost = \"test-only.example.com\"\n")
|
||||
commitDomainDiff(t, root, "add test-only public policy")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") {
|
||||
t.Fatalf("violations = %+v, want test-only.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed Go parse failure", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n")
|
||||
commitDomainDiff(t, root, "break source")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "target.go" {
|
||||
t.Fatalf("violations = %+v, want target.go scan-incomplete", got)
|
||||
}
|
||||
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
|
||||
t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repository type loading failure", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+
|
||||
"require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nimport _ \"example.com/missing\"\n\n"+
|
||||
"type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n")
|
||||
commitDomainDiff(t, root, "break type loading")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" {
|
||||
t.Fatalf("violations = %+v, want go.mod scan-incomplete", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Message, "load Go type information") {
|
||||
t.Fatalf("message = %q, want type-loading failure", got[0].Message)
|
||||
}
|
||||
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
|
||||
t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused)
|
||||
}
|
||||
})
|
||||
}
|
||||
380
lint/domaincontract/unapproved_test.go
Normal file
380
lint/domaincontract/unapproved_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func scanDomainEvidence(t *testing.T, source string) []domainEvidence {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixture: %v\n%s", err, source)
|
||||
}
|
||||
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset})
|
||||
scan.collectSemanticEvidence()
|
||||
scan.collectAbsoluteURLEvidence()
|
||||
sort.Slice(scan.Evidence, func(i, j int) bool {
|
||||
if scan.Evidence[i].Host != scan.Evidence[j].Host {
|
||||
return scan.Evidence[i].Host < scan.Evidence[j].Host
|
||||
}
|
||||
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
|
||||
})
|
||||
return scan.Evidence
|
||||
}
|
||||
|
||||
func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence {
|
||||
t.Helper()
|
||||
return scanTypedDomainEvidenceInPackage(t, "fixture", source)
|
||||
}
|
||||
|
||||
func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixture: %v\n%s", err, source)
|
||||
}
|
||||
info := &types.Info{
|
||||
Types: map[ast.Expr]types.TypeAndValue{},
|
||||
Defs: map[*ast.Ident]types.Object{},
|
||||
Uses: map[*ast.Ident]types.Object{},
|
||||
Selections: map[*ast.SelectorExpr]*types.Selection{},
|
||||
}
|
||||
if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil {
|
||||
t.Fatalf("type-check fixture: %v\n%s", err, source)
|
||||
}
|
||||
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info})
|
||||
scan.collectSemanticEvidence()
|
||||
scan.collectAbsoluteURLEvidence()
|
||||
sort.Slice(scan.Evidence, func(i, j int) bool {
|
||||
if scan.Evidence[i].Host != scan.Evidence[j].Host {
|
||||
return scan.Evidence[i].Host < scan.Evidence[j].Host
|
||||
}
|
||||
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
|
||||
})
|
||||
return scan.Evidence
|
||||
}
|
||||
|
||||
func evidenceHosts(evidence []domainEvidence) []string {
|
||||
hosts := make([]string, 0, len(evidence))
|
||||
for _, item := range evidence {
|
||||
hosts = append(hosts, item.Host)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) {
|
||||
evidence := scanTypedDomainEvidence(t,
|
||||
"package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n")
|
||||
if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" {
|
||||
t.Fatalf("hosts = %v, want [private.corp.internal]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoDomainEvidenceTruePositives(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "PR 1975 Feishu assignment",
|
||||
source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n",
|
||||
want: []string{"internal-api-drive-stream.feishu.cn"},
|
||||
},
|
||||
{
|
||||
name: "PR 1975 Lark assignment",
|
||||
source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n",
|
||||
want: []string{"internal-api-drive-stream.larksuite.com"},
|
||||
},
|
||||
{
|
||||
name: "uppercase snake target",
|
||||
source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "typed declaration",
|
||||
source: "package p\nconst APIHost string = \"attacker.zip\"\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "grouped const declaration",
|
||||
source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "grouped var declaration",
|
||||
source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "multi assignment",
|
||||
source: "package p\nfunc f() {\n" +
|
||||
" APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" +
|
||||
" _, _ = APIHost, BackupHost\n}\n",
|
||||
want: []string{"attacker.zip", "public.example.com"},
|
||||
},
|
||||
{
|
||||
name: "map semantic key",
|
||||
source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "map semantic key assignment",
|
||||
source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "host collection values",
|
||||
source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n",
|
||||
want: []string{"attacker.zip", "private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "host collection map keys",
|
||||
source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "host collection bool map keys",
|
||||
source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n",
|
||||
want: []string{"api.example.com"},
|
||||
},
|
||||
{
|
||||
name: "host collection map values",
|
||||
source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n",
|
||||
want: []string{"api.example.com"},
|
||||
},
|
||||
{
|
||||
name: "host collection map value assignment",
|
||||
source: "package p\nfunc f() {\n" +
|
||||
" HostsByRegion := map[string]string{}\n" +
|
||||
" HostsByRegion[\"sg\"] = \"api.example.com\"\n" +
|
||||
"}\n",
|
||||
want: []string{"api.example.com"},
|
||||
},
|
||||
{
|
||||
name: "static concatenation",
|
||||
source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "multiline assignment",
|
||||
source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "escaped hostname",
|
||||
source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "hex escaped hostname",
|
||||
source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "octal escaped hostname",
|
||||
source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "raw hostname",
|
||||
source: "package p\nvar APIHost = `private.corp.internal`\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "same-file constant reference",
|
||||
source: "package p\nconst existingConst = \"private.corp.internal\"\n" +
|
||||
"func f() { APIHost := existingConst; _ = APIHost }\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "absolute URL",
|
||||
source: "package p\nvar message = \"https://private.corp.internal/v1\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "websocket URL with port",
|
||||
source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "URL userinfo query and fragment",
|
||||
source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "IDN hostname",
|
||||
source: "package p\nvar APIHost = \"例子.公司.cn\"\n",
|
||||
want: []string{"例子.公司.cn"},
|
||||
},
|
||||
{
|
||||
name: "case port and trailing dot normalization",
|
||||
source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n",
|
||||
want: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := evidenceHosts(scanDomainEvidence(t, tc.source))
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("hosts = %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("hosts = %v, want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoDomainEvidenceTrueNegatives(t *testing.T) {
|
||||
source := `package p
|
||||
|
||||
import _ "github.com/larksuite/oapi-sdk-go/v3"
|
||||
|
||||
var file = "archive.zip"
|
||||
var event = "card.action.trigger"
|
||||
var schema = "im.messages.list"
|
||||
var configFile = "service.prod.json"
|
||||
var version = "v1.2.3"
|
||||
var email = "name@example.com"
|
||||
var lowConfidence = "attacker.zip"
|
||||
var downloadURL = "archive.zip/file"
|
||||
var prose = "See https://private.corp.internal/v1 for details"
|
||||
// https://private.corp.internal/v1
|
||||
var ghost = "private.corp.internal"
|
||||
var hostnameParser = "private.corp.internal"
|
||||
var domainError = "private.corp.internal"
|
||||
var APIHost = "localhost"
|
||||
var BackupHost = "127.0.0.1"
|
||||
var hosts = struct{ File string }{File: "archive.zip"}
|
||||
var AllowedHosts = map[string]string{"api.example.com": "client.pem"}
|
||||
|
||||
func dynamicValue() string { return "private.corp.internal" }
|
||||
var DynamicHost = dynamicValue()
|
||||
|
||||
func setAmbiguousHostMetadata() {
|
||||
AllowedHosts["api.example.com"] = "client.pem"
|
||||
}
|
||||
`
|
||||
if got := scanDomainEvidence(t, source); len(got) != 0 {
|
||||
t.Fatalf("unexpected evidence: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedStructFieldHostnameSemantics(t *testing.T) {
|
||||
t.Run("network fields", func(t *testing.T) {
|
||||
source := `package source
|
||||
|
||||
type Config struct { Host string }
|
||||
type FeishuSource struct { Domain string }
|
||||
|
||||
var config = Config{Host: "api.example.com"}
|
||||
var source = FeishuSource{Domain: "events.example.com"}
|
||||
`
|
||||
got := evidenceHosts(scanTypedDomainEvidenceInPackage(
|
||||
t,
|
||||
"github.com/larksuite/cli/internal/event/source",
|
||||
source,
|
||||
))
|
||||
want := []string{"api.example.com", "events.example.com"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("command metadata domain", func(t *testing.T) {
|
||||
source := `package cmdmeta
|
||||
|
||||
type Meta struct { Domain string }
|
||||
|
||||
var meta = Meta{Domain: "im.messages"}
|
||||
func update(meta *Meta) { meta.Domain = "docs.pages" }
|
||||
`
|
||||
if got := scanTypedDomainEvidenceInPackage(
|
||||
t,
|
||||
"github.com/larksuite/cli/internal/cmdmeta",
|
||||
source,
|
||||
); len(got) != 0 {
|
||||
t.Fatalf("unexpected command metadata evidence: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card action host", func(t *testing.T) {
|
||||
source := `package im
|
||||
|
||||
type CardActionTriggerOutput struct { Host string }
|
||||
|
||||
var output = CardActionTriggerOutput{Host: "card.action"}
|
||||
func update(output *CardActionTriggerOutput) { output.Host = "im.message" }
|
||||
`
|
||||
if got := scanTypedDomainEvidenceInPackage(
|
||||
t,
|
||||
"github.com/larksuite/cli/events/im",
|
||||
source,
|
||||
); len(got) != 0 {
|
||||
t.Fatalf("unexpected card host evidence: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field ownership is conservative", func(t *testing.T) {
|
||||
source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n"
|
||||
if got := scanDomainEvidence(t, source); len(got) != 0 {
|
||||
t.Fatalf("unexpected untyped field evidence: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHostnameSemanticNames(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"host", "HOST", "hosts", "hostname", "domains",
|
||||
"api_host", "API_HOST", "ALLOWED_HOSTS",
|
||||
"apiHost", "APIHost", "backupHostname",
|
||||
"HostsByRegion", "APIHostsByRegion", "hostsByRegion",
|
||||
} {
|
||||
if !isHostnameSemanticName(name) {
|
||||
t.Errorf("%q should be hostname-semantic", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin",
|
||||
"HostBypass", "APIHostBypass",
|
||||
} {
|
||||
if isHostnameSemanticName(name) {
|
||||
t.Errorf("%q must not be hostname-semantic", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainFixturePaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"internal/x/x_test.go",
|
||||
"tests/cli_e2e/x.go",
|
||||
"internal/x/testdata/sample.go",
|
||||
} {
|
||||
if !isDomainFixturePath(path) {
|
||||
t.Errorf("%q should be fixture scope", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"internal/x/test_helper.go",
|
||||
"examples/demo.go",
|
||||
"skills/example/testdata/sample.go",
|
||||
"skills/example/example_test.go",
|
||||
} {
|
||||
if isDomainFixturePath(path) {
|
||||
t.Errorf("%q must not be fixture scope", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
lint/main.go
10
lint/main.go
@@ -3,7 +3,7 @@
|
||||
|
||||
// Command lintcheck runs repository source-contract guards that golangci-lint
|
||||
// cannot express directly. It currently covers typed-error contracts and the
|
||||
// resolver-owned endpoint contract.
|
||||
// resolver-owned endpoint and approved-domain contracts.
|
||||
//
|
||||
// lintcheck lives in its own Go module under lint/ so its build-time
|
||||
// dependency on golang.org/x/tools/go/packages does not leak into the
|
||||
@@ -43,8 +43,10 @@ type scanner struct {
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepo(root)
|
||||
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ func main() {
|
||||
"Runs every registered lint domain against repo-root (default: current directory).\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
|
||||
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks")
|
||||
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
|
||||
flag.Parse()
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.79",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.79",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.79",
|
||||
"version": "1.0.80",
|
||||
"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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -25,14 +26,25 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{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}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
|
||||
"Each new question creates a field in the form's table; question IDs are field IDs.",
|
||||
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return common.NewDryRunAPI().
|
||||
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"))
|
||||
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")
|
||||
@@ -40,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",
|
||||
@@ -71,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/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
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:block:read",
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
@@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
baseToken := firstPathSegmentAfter(parsed.Path, "/base/")
|
||||
if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(map[string]interface{}{}).
|
||||
Set("base_token", baseToken).
|
||||
Set("selected_block_id", selectedBlockID)
|
||||
}
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
dry := common.NewDryRunAPI()
|
||||
selectedBlockID := strings.TrimSpace(parsed.Query().Get("table"))
|
||||
if selectedBlockID == "" {
|
||||
return dry.
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
}
|
||||
dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block")
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve the Wiki node to its underlying Base").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Desc("[2] List Base blocks and match selected_block_id").
|
||||
Body(map[string]interface{}{})
|
||||
return dry.
|
||||
Set("base_token", "<obj_token from step 1>").
|
||||
Set("selected_block_id", selectedBlockID)
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
@@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
@@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selection := resolveBaseURLSelection(parsed)
|
||||
applyBaseURLSelection(out, selection)
|
||||
enrichBaseResolveHint(runtime, out, selection)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
@@ -251,24 +279,50 @@ func classifyBaseURL(u *url.URL) string {
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
applyBaseURLSelection(out, resolveBaseURLSelection(u))
|
||||
return out
|
||||
}
|
||||
|
||||
type baseURLSelection struct {
|
||||
blockID string
|
||||
viewID string
|
||||
recordID string
|
||||
}
|
||||
|
||||
func resolveBaseURLSelection(u *url.URL) baseURLSelection {
|
||||
query := u.Query()
|
||||
return baseURLSelection{
|
||||
blockID: strings.TrimSpace(query.Get("table")),
|
||||
viewID: strings.TrimSpace(query.Get("view")),
|
||||
recordID: strings.TrimSpace(query.Get("record")),
|
||||
}
|
||||
}
|
||||
|
||||
func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) {
|
||||
if selection.blockID != "" {
|
||||
// The Base web UI historically uses the query key "table" for the
|
||||
// currently selected top-level block. Its value can identify a table,
|
||||
// dashboard, workflow, or another block type. Keep it neutral until the
|
||||
// block directory confirms the resource type.
|
||||
out["block_id"] = selection.blockID
|
||||
out["selection_source"] = "url_query"
|
||||
}
|
||||
}
|
||||
|
||||
func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) {
|
||||
if selection.viewID != "" {
|
||||
out["view_id"] = selection.viewID
|
||||
}
|
||||
if selection.recordID != "" {
|
||||
out["record_id"] = selection.recordID
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
@@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
|
||||
if baseToken == "" || selectedBlockID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
|
||||
out["block_type"] = block.Type
|
||||
if block.Name != "" {
|
||||
out["block_name"] = block.Name
|
||||
}
|
||||
switch block.Type {
|
||||
case "table":
|
||||
applyResolvedTableSelection(out, selection)
|
||||
enrichResolvedTable(runtime, out, baseToken, selectedBlockID)
|
||||
case "dashboard":
|
||||
out["dashboard_id"] = selectedBlockID
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard",
|
||||
}
|
||||
case "workflow":
|
||||
out["workflow_id"] = selectedBlockID
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": "use +workflow-get to inspect the resolved workflow",
|
||||
}
|
||||
case "folder":
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID),
|
||||
}
|
||||
case "docx":
|
||||
if block.DocxToken != "" {
|
||||
out["docx_token"] = block.DocxToken
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken),
|
||||
}
|
||||
} else {
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token",
|
||||
}
|
||||
}
|
||||
default:
|
||||
out["hint"] = resolveUnknownBlockHint()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
out["hint"] = resolveUnknownBlockHint()
|
||||
}
|
||||
|
||||
type resolvedBaseBlock struct {
|
||||
ID string
|
||||
Type string
|
||||
Name string
|
||||
DocxToken string
|
||||
}
|
||||
|
||||
func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return resolvedBaseBlock{}, false, err
|
||||
}
|
||||
for _, item := range common.GetSlice(data, "blocks") {
|
||||
row, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
block := resolvedBaseBlock{
|
||||
ID: strings.TrimSpace(common.GetString(row, "id")),
|
||||
Type: strings.TrimSpace(common.GetString(row, "type")),
|
||||
Name: strings.TrimSpace(common.GetString(row, "name")),
|
||||
DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")),
|
||||
}
|
||||
if block.ID == selectedBlockID {
|
||||
return block, true, nil
|
||||
}
|
||||
}
|
||||
return resolvedBaseBlock{}, false, nil
|
||||
}
|
||||
|
||||
func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) {
|
||||
out["table_id"] = tableID
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
@@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func resolveUnknownBlockHint() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block",
|
||||
}
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,6 +18,9 @@ import (
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
|
||||
))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
@@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
@@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
if data["base_token"] != "bas123" || data["block_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("unconfirmed block must not be reported as a table: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
|
||||
))
|
||||
fieldStub := fieldListStub("bas123", "tbl123")
|
||||
fieldStub.Optional = true
|
||||
fieldStub.OnMatch = func(_ *http.Request) {
|
||||
t.Fatalf("field endpoint must not be used to infer selected block type")
|
||||
}
|
||||
reg.Register(fieldStub)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected block coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["block_type"]; ok {
|
||||
t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("untyped block must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint)
|
||||
}
|
||||
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dashboard selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" {
|
||||
t.Fatalf("unexpected dashboard coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("dashboard must not be reported as table_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") {
|
||||
t.Fatalf("unexpected dashboard hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workflow selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" {
|
||||
t.Fatalf("unexpected workflow coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("workflow must not be reported as table_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("workflow must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("workflow must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if !strings.Contains(hint["next_step"].(string), "+workflow-get") {
|
||||
t.Fatalf("unexpected workflow hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("folder selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" {
|
||||
t.Fatalf("unexpected folder coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("folder must not be reported as table_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") {
|
||||
t.Fatalf("unexpected folder hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("docx selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" {
|
||||
t.Fatalf("unexpected docx coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("docx must not be reported as table_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") {
|
||||
t.Fatalf("unexpected docx hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub {
|
||||
items := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
items = append(items, block)
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": items,
|
||||
"total": len(items),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
@@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bitable with table coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
|
||||
))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected Wiki Base table coordinates: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bitable with dashboard selection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" {
|
||||
t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": baseToken,
|
||||
"title": title,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -98,6 +98,26 @@ func TestCallAPITyped_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallAPITyped_ExtraHeaderFromEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_NAME", "x-tt-env")
|
||||
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_VALUE", "boe_whiteboard_test")
|
||||
rt, reg := newCallAPITypedRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/wb/nodes/batch_update",
|
||||
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"ids": []interface{}{"a1:1"}}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
_, err := rt.CallAPITyped("PUT", "/open-apis/board/v1/whiteboards/wb/nodes/batch_update", nil, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("x-tt-env"); got != "boe_whiteboard_test" {
|
||||
t.Fatalf("x-tt-env header = %q, want boe_whiteboard_test", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIClassifyContext verifies the classify context is built from the
|
||||
// runtime: Brand / AppID from config, Identity from the resolved caller, and
|
||||
// LarkCmd from the running command path.
|
||||
|
||||
447
shortcuts/contact/contact_search_bot.go
Normal file
447
shortcuts/contact/contact_search_bot.go
Normal file
@@ -0,0 +1,447 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const botSearchURL = "/open-apis/bot/v4/bot/search"
|
||||
|
||||
const (
|
||||
maxBotSearchQueryChars = 50
|
||||
maxBotSearchChatIDs = 100
|
||||
maxBotSearchPageSize = 30
|
||||
)
|
||||
|
||||
type botSearchAPIRequest struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Filter *botSearchAPIFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// HasChatter uses omitempty: validation rejects =false, so a set field is always
|
||||
// true and an unset field stays out of the request entirely.
|
||||
type botSearchAPIFilter struct {
|
||||
ChatIDs []string `json:"chat_ids,omitempty"`
|
||||
HasChatter bool `json:"has_chatter,omitempty"`
|
||||
}
|
||||
|
||||
type botSearchAPIData struct {
|
||||
Items []botSearchAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type botSearchAPIItem struct {
|
||||
ID string `json:"id"`
|
||||
DisplayInfo string `json:"display_info"`
|
||||
MetaData botSearchAPIMeta `json:"meta_data"`
|
||||
}
|
||||
|
||||
type botSearchAPIMeta struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
EnableJoinGroup bool `json:"enable_join_group"`
|
||||
ChatID string `json:"chat_id"`
|
||||
IsAgent bool `json:"is_agent"`
|
||||
}
|
||||
|
||||
type searchBot struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// ChatID is the caller's P2P chat with the bot.
|
||||
ChatID string `json:"chat_id"`
|
||||
EnableJoinGroup bool `json:"enable_join_group"`
|
||||
IsAgent bool `json:"is_agent"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
MatchSegments []string `json:"match_segments"`
|
||||
}
|
||||
|
||||
// PageToken is decoded from the response but deliberately not surfaced, matching
|
||||
// searchUserResponse: neither search command paginates. Callers narrow the query
|
||||
// instead, so handing out a token that no flag accepts would only mislead.
|
||||
type searchBotResponse struct {
|
||||
Bots []searchBot `json:"bots"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchBot = common.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+search-bot",
|
||||
Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"},
|
||||
{Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"},
|
||||
{Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotSearch(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
|
||||
filter, err := buildBotSearchFilter(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
api := common.NewDryRunAPI()
|
||||
for _, q := range parseAndDedupQueries(raw) {
|
||||
body := &botSearchAPIRequest{Query: q, Filter: filter}
|
||||
api.POST(botSearchURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
}
|
||||
return api
|
||||
}
|
||||
body, err := buildBotSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(botSearchURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
},
|
||||
Execute: executeBotSearch,
|
||||
}
|
||||
|
||||
// executeBotSearch dispatches to single-query or fanout mode.
|
||||
func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeBotSearchFanout(ctx, runtime)
|
||||
}
|
||||
return executeBotSearchSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// botSearchKeywordRequiredError names every flag that can satisfy the keyword
|
||||
// requirement. Naming only --query would tell an agent that --queries is not a
|
||||
// way out, which it is.
|
||||
func botSearchKeywordRequiredError() error {
|
||||
return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"},
|
||||
)
|
||||
}
|
||||
|
||||
// botSearchHasChattedFalseError is raised from two places — with and without a
|
||||
// keyword — so the wording stays in one spot.
|
||||
//
|
||||
// Agents passing =false almost always mean "do not filter", but the API reads it
|
||||
// as "must NOT match". A hard error prevents silent wrong results.
|
||||
func botSearchHasChattedFalseError() error {
|
||||
return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)").
|
||||
WithParam("--has-chatted")
|
||||
}
|
||||
|
||||
func validateBotSearch(runtime *common.RuntimeContext) error {
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
query := strings.TrimSpace(runtime.Str("query"))
|
||||
explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted")
|
||||
|
||||
if queriesRaw != "" {
|
||||
if query != "" {
|
||||
return common.ValidationErrorf("--query and --queries are mutually exclusive").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
|
||||
)
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
|
||||
WithParam("--queries")
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
|
||||
WithParam("--queries")
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxBotSearchQueryChars {
|
||||
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars).
|
||||
WithParam("--queries")
|
||||
}
|
||||
}
|
||||
} else if query == "" {
|
||||
// No keyword at all. An explicit =false is the more specific mistake, so
|
||||
// report it instead of sending the caller off to add a keyword only to hit
|
||||
// this on the next attempt. +search-user lands here too: a Changed bool
|
||||
// counts as search input for its "at least one" gate, so the =false check
|
||||
// is what it reaches next.
|
||||
//
|
||||
// Scoped to the no-keyword case on purpose. Hoisting it above the keyword
|
||||
// checks would let it mask the mutual-exclusion and length errors, which
|
||||
// +search-user reports first when a keyword is present.
|
||||
if explicitFalseHasChatted {
|
||||
return botSearchHasChattedFalseError()
|
||||
}
|
||||
return botSearchKeywordRequiredError()
|
||||
} else if utf8.RuneCountInString(query) > maxBotSearchQueryChars {
|
||||
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars).
|
||||
WithParam("--query")
|
||||
}
|
||||
|
||||
if _, err := parseBotSearchChatIDs(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if explicitFalseHasChatted {
|
||||
return botSearchHasChattedFalseError()
|
||||
}
|
||||
|
||||
if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize {
|
||||
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize).
|
||||
WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
|
||||
raw := strings.TrimSpace(runtime.Str("chat-ids"))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := common.SplitCSV(raw)
|
||||
if len(parts) == 0 {
|
||||
return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw).
|
||||
WithParam("--chat-ids")
|
||||
}
|
||||
|
||||
// Normalize before deduping, then check the cap against the deduped list —
|
||||
// the same order common.resolveOpenIDs uses for --user-ids. Doing it the other
|
||||
// way would spend the server's 100-entry budget on duplicates, and would let
|
||||
// 101 copies of one chat be rejected here while the sibling command accepts
|
||||
// them. Normalization matters too: a chat URL and a bare chat_id can name the
|
||||
// same chat.
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
chatIDs := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
normalized, err := common.ValidateChatIDTyped("--chat-ids", part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := seen[normalized]; dup {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
chatIDs = append(chatIDs, normalized)
|
||||
}
|
||||
if len(chatIDs) > maxBotSearchChatIDs {
|
||||
return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs).
|
||||
WithParam("--chat-ids")
|
||||
}
|
||||
return chatIDs, nil
|
||||
}
|
||||
|
||||
// buildBotSearchFilter reads the scope flags shared by single and fanout search.
|
||||
// A nil filter means "no scope": an empty filter object is not the same request.
|
||||
func buildBotSearchFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) {
|
||||
filter := &botSearchAPIFilter{}
|
||||
hasFilter := false
|
||||
|
||||
chatIDs, err := parseBotSearchChatIDs(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(chatIDs) > 0 {
|
||||
filter.ChatIDs = chatIDs
|
||||
hasFilter = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") {
|
||||
filter.HasChatter = true
|
||||
hasFilter = true
|
||||
}
|
||||
|
||||
if !hasFilter {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
|
||||
filter, err := buildBotSearchFilter(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &botSearchAPIRequest{
|
||||
Query: strings.TrimSpace(runtime.Str("query")),
|
||||
Filter: filter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the
|
||||
// response envelope — notice, has_more, and in fanout mode queries[] — into
|
||||
// stdout. Only json does; pretty, table, csv and ndjson render rows only, so
|
||||
// every piece of "this result is not the whole answer" metadata would vanish and
|
||||
// the caller would read a truncated result as a complete one. For those formats
|
||||
// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression
|
||||
// can still project it away, but that is the caller's explicit choice.
|
||||
func botSearchStdoutCarriesEnvelope(format string) bool {
|
||||
return format == "json" || format == ""
|
||||
}
|
||||
|
||||
func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildBotSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: botSearchURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respData, err := decodeBotSearchAPIData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bots := projectBots(respData)
|
||||
out := searchBotResponse{
|
||||
Bots: bots,
|
||||
HasMore: respData.HasMore,
|
||||
Notice: respData.Notice,
|
||||
}
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) {
|
||||
if len(bots) == 0 {
|
||||
fmt.Fprintln(w, "No bots found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyBotRows(bots))
|
||||
})
|
||||
if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
|
||||
}
|
||||
if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err)
|
||||
}
|
||||
var out botSearchAPIData
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func projectBots(data *botSearchAPIData) []searchBot {
|
||||
if data == nil {
|
||||
return []searchBot{}
|
||||
}
|
||||
bots := make([]searchBot, 0, len(data.Items))
|
||||
for i := range data.Items {
|
||||
item := &data.Items[i]
|
||||
name, description, segments := parseBotDisplayInfo(item.DisplayInfo)
|
||||
bots = append(bots, searchBot{
|
||||
OpenID: item.ID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
ChatID: item.MetaData.ChatID,
|
||||
EnableJoinGroup: item.MetaData.EnableJoinGroup,
|
||||
IsAgent: item.MetaData.IsAgent,
|
||||
TenantID: item.MetaData.TenantID,
|
||||
MatchSegments: segments,
|
||||
})
|
||||
}
|
||||
return bots
|
||||
}
|
||||
|
||||
func stripHighlightTags(value string) string {
|
||||
value = strings.ReplaceAll(value, "<h>", "")
|
||||
return strings.ReplaceAll(value, "</h>", "")
|
||||
}
|
||||
|
||||
func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
|
||||
matchSegments = make([]string, 0)
|
||||
for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) {
|
||||
// The capture can still carry a tag: the non-greedy pattern pairs a
|
||||
// stray `<h>` with the next `</h>`. Strip it so a segment reads like the
|
||||
// name and description it came from, and drop a highlight with no text.
|
||||
segment := html.UnescapeString(stripHighlightTags(match[1]))
|
||||
if strings.TrimSpace(segment) == "" {
|
||||
continue
|
||||
}
|
||||
matchSegments = append(matchSegments, segment)
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
stripTags := func(value string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(stripHighlightTags(value)))
|
||||
}
|
||||
|
||||
// nameLine records which line the name came from, so the description is read
|
||||
// from the line after it. Reading lines[1] unconditionally echoes the name
|
||||
// back as its own description whenever line 0 is blank, and drops the real
|
||||
// description with it.
|
||||
nameLine := -1
|
||||
if len(lines) > 0 {
|
||||
if candidate := stripTags(lines[0]); candidate != "" {
|
||||
name = candidate
|
||||
nameLine = 0
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
for i, line := range lines {
|
||||
if candidate := stripTags(line); candidate != "" {
|
||||
name = candidate
|
||||
nameLine = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if nameLine >= 0 && nameLine+1 < len(lines) {
|
||||
description = stripTags(lines[nameLine+1])
|
||||
}
|
||||
return name, description, matchSegments
|
||||
}
|
||||
|
||||
// map[] shape is required by output.PrintTable.
|
||||
func prettyBotRows(bots []searchBot) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(bots))
|
||||
for _, bot := range bots {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"name": bot.Name,
|
||||
"description": common.TruncateStr(bot.Description, 50),
|
||||
"is_agent": bot.IsAgent,
|
||||
"enable_join_group": bot.EnableJoinGroup,
|
||||
"open_id": bot.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
289
shortcuts/contact/contact_search_bot_fanout.go
Normal file
289
shortcuts/contact/contact_search_bot_fanout.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// Bot fanout reuses the user fanout's query parsing, concurrency limit and
|
||||
// response summary types.
|
||||
|
||||
type botFanoutResult struct {
|
||||
Index int
|
||||
Query string
|
||||
Bots []searchBot
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed propagation
|
||||
}
|
||||
|
||||
// runOneBotQuery converts one fanout request into either bots or an error summary.
|
||||
func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *botSearchAPIFilter) botFanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a request;
|
||||
// in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
body := &botSearchAPIRequest{Query: query}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: botSearchURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
respData, err := decodeBotSearchAPIData(data)
|
||||
if err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
return botFanoutResult{
|
||||
Index: index,
|
||||
Query: query,
|
||||
Bots: projectBots(respData),
|
||||
HasMore: respData.HasMore,
|
||||
Notice: respData.Notice,
|
||||
}
|
||||
}
|
||||
|
||||
// botFanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func botFanoutErrorResult(index int, query string, err error) botFanoutResult {
|
||||
if err == nil {
|
||||
return botFanoutResult{Index: index, Query: query}
|
||||
}
|
||||
return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
|
||||
}
|
||||
|
||||
func botFanoutContextError(err error) error {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
message := "bot search fanout cancelled"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
message = "bot search fanout deadline exceeded"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "%s", message).WithCause(err)
|
||||
}
|
||||
|
||||
func botFanoutPanicError(query string, recovered any) error {
|
||||
err := errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"bot search query %q panicked: %v", query, recovered)
|
||||
if cause, ok := recovered.(error); ok {
|
||||
return err.WithCause(cause)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Terminal failures invalidate the batch; API and network failures remain
|
||||
// eligible for partial-success reporting.
|
||||
func botFanoutTerminalError(results []botFanoutResult) error {
|
||||
for _, result := range results {
|
||||
if result.Err == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) {
|
||||
return botFanoutContextError(result.Err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(result.Err)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"bot search query %q failed with an unclassified error: %v", result.Query, result.Err).
|
||||
WithCause(result.Err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork {
|
||||
return result.Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fanoutBot struct {
|
||||
searchBot
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
}
|
||||
|
||||
type botFanoutResponse struct {
|
||||
Bots []fanoutBot `json:"bots"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildBotFanoutResponse flattens recoverable results in query order. Terminal
|
||||
// errors fail the batch even when another query succeeded.
|
||||
func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) {
|
||||
if err := botFanoutTerminalError(results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indexed := make([]botFanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
indexed[r.Index] = r
|
||||
}
|
||||
|
||||
out := &botFanoutResponse{
|
||||
Bots: make([]fanoutBot, 0),
|
||||
Queries: make([]querySummary, 0, len(queries)),
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErr error
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErr = r.Err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, b := range r.Bots {
|
||||
out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]})
|
||||
}
|
||||
}
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
return nil, contactFanoutAllFailedError(firstErr, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queries := parseAndDedupQueries(runtime.Str("queries"))
|
||||
|
||||
filter, err := buildBotSearchFilter(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]botFanoutResult, len(queries))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, fanoutConcurrency)
|
||||
|
||||
schedule:
|
||||
for i, q := range queries {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
for j := i; j < len(queries); j++ {
|
||||
results[j] = botFanoutErrorResult(j, queries[j], ctx.Err())
|
||||
}
|
||||
break schedule
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, q string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err := botFanoutPanicError(q, r)
|
||||
results[i] = botFanoutResult{
|
||||
Index: i,
|
||||
Query: q,
|
||||
ErrMsg: contactFanoutErrorSummary(err),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}()
|
||||
results[i] = runOneBotQuery(ctx, runtime, i, q, filter)
|
||||
}(i, q)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
resp, err := buildBotFanoutResponse(queries, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
failed, hasMoreCount := 0, 0
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
failed++
|
||||
}
|
||||
if qs.HasMore {
|
||||
hasMoreCount++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) {
|
||||
if len(resp.Bots) == 0 {
|
||||
fmt.Fprintln(w, "No bots found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyBotFanoutRows(resp.Bots))
|
||||
})
|
||||
|
||||
if isFanoutSummaryFormat(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n",
|
||||
len(queries), len(resp.Bots), failed, hasMoreCount)
|
||||
}
|
||||
// The counts above say how many queries failed but not which, and only the
|
||||
// json envelope carries queries[].error / queries[].notice. Without this an
|
||||
// agent reading csv or a table sees "1 failed" with no way to learn the
|
||||
// keyword or the reason, and a notice disappears entirely.
|
||||
if !botSearchStdoutCarriesEnvelope(runtime.Format) {
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error)
|
||||
}
|
||||
if qs.Notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice)
|
||||
}
|
||||
if qs.HasMore {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(bots))
|
||||
for _, bot := range bots {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"matched_query": bot.MatchedQuery,
|
||||
"name": bot.Name,
|
||||
"description": common.TruncateStr(bot.Description, 50),
|
||||
"is_agent": bot.IsAgent,
|
||||
"enable_join_group": bot.EnableJoinGroup,
|
||||
"open_id": bot.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal file
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) {
|
||||
r := botFanoutErrorResult(3, "会议助手", nil)
|
||||
if r.ErrMsg != "" || r.Err != nil {
|
||||
t.Fatalf("nil error must stay a success result: %+v", r)
|
||||
}
|
||||
if r.Index != 3 || r.Query != "会议助手" {
|
||||
t.Fatalf("index/query must survive: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAssembleOrderAndShape(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true},
|
||||
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}},
|
||||
{Index: 2, Query: "审批", ErrMsg: "API 1: nope"},
|
||||
}
|
||||
resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Results are emitted in query order even though the workers finished out of
|
||||
// order, and a failed query contributes no rows.
|
||||
wantRows := []struct {
|
||||
openID, matched string
|
||||
}{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}}
|
||||
if len(resp.Bots) != len(wantRows) {
|
||||
t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows))
|
||||
}
|
||||
for i, w := range wantRows {
|
||||
if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched {
|
||||
t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched)
|
||||
}
|
||||
}
|
||||
|
||||
want := []querySummary{
|
||||
{Query: "会议"},
|
||||
{Query: "日报", HasMore: true},
|
||||
{Query: "审批", Error: "API 1: nope"},
|
||||
}
|
||||
if len(resp.Queries) != len(want) {
|
||||
t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if resp.Queries[i] != w {
|
||||
t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)},
|
||||
{Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"},
|
||||
}
|
||||
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when every query fails")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed problem, got %T: %v", err, err)
|
||||
}
|
||||
// The first failure's classification must survive, so the caller can tell a
|
||||
// rate limit apart from a transport fault.
|
||||
if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
// Agents grep the count and the first failure out of this message.
|
||||
for _, want := range []string{"all 2 queries failed", "rate limit"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("message must contain %q; got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
|
||||
{Index: 1, Query: "日报", ErrMsg: "API 1: nope"},
|
||||
}
|
||||
resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("one failure out of two must not fail the call: %v", err)
|
||||
}
|
||||
if len(resp.Bots) != 1 || resp.Queries[1].Error == "" {
|
||||
t.Fatalf("partial failure shape: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantSubtype errs.Subtype
|
||||
}{
|
||||
{name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport},
|
||||
{name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
|
||||
botFanoutErrorResult(1, "日报", tt.err),
|
||||
}
|
||||
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err == nil {
|
||||
t.Fatal("terminal context error must fail the batch after a partial success")
|
||||
}
|
||||
if !errors.Is(err, tt.err) {
|
||||
t.Fatalf("error must preserve %v as its cause: %v", tt.err, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) {
|
||||
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// has_more is per query in the sidecar; a single top-level flag would hide
|
||||
// which keyword was truncated.
|
||||
if _, ok := envelope["has_more"]; ok {
|
||||
t.Fatalf("fanout must not surface a top-level has_more: %s", raw)
|
||||
}
|
||||
if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) {
|
||||
t.Fatalf("per-query has_more lost: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) {
|
||||
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"bots":[]`) {
|
||||
t.Fatalf("empty bots must serialize as [], not null: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) {
|
||||
rows := prettyBotFanoutRows([]fanoutBot{{
|
||||
searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)},
|
||||
MatchedQuery: "会议",
|
||||
}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows: %d", len(rows))
|
||||
}
|
||||
if rows[0]["matched_query"] != "会议" {
|
||||
t.Errorf("matched_query missing: %+v", rows[0])
|
||||
}
|
||||
if got := rows[0]["description"].(string); len([]rune(got)) > 51 {
|
||||
t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "query", "会议")
|
||||
setBotSearchFlag(t, cmd, "queries", "会议,日报")
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
|
||||
err := validateBotSearch(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected mutual-exclusion error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem: %+v ok=%v", problem, ok)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutValidationLimits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queries string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "nothing parses", queries: " , , ", wantParam: "--queries"},
|
||||
{name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"},
|
||||
{name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
queries := tt.queries
|
||||
if strings.Contains(queries, "%d") {
|
||||
parts := make([]string, 0, maxFanoutQueries+1)
|
||||
for i := 0; i <= maxFanoutQueries; i++ {
|
||||
parts = append(parts, fmt.Sprintf("q%d", i))
|
||||
}
|
||||
queries = strings.Join(parts, ",")
|
||||
}
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "queries", queries)
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --queries alone is enough: the single-search "--query is required" rule must not
|
||||
// leak into fanout mode.
|
||||
func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手")
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
if err := validateBotSearch(runtime); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
stub.Reusable = true
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(stub.CapturedBodies) != 2 {
|
||||
t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies))
|
||||
}
|
||||
seen := make(map[string]bool, len(stub.CapturedBodies))
|
||||
for i, raw := range stub.CapturedBodies {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal req %d: %v", i, err)
|
||||
}
|
||||
seen[fmt.Sprint(body["query"])] = true
|
||||
filter, ok := body["filter"].(map[string]interface{})
|
||||
if !ok || filter["has_chatter"] != true {
|
||||
t.Fatalf("filter must ride along with every query: %#v", body)
|
||||
}
|
||||
}
|
||||
for _, q := range []string{"会议", "日报"} {
|
||||
if !seen[q] {
|
||||
t.Fatalf("query %q never issued; saw %v", q, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
dedupStub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
dedupStub.Reusable = true
|
||||
registry.Register(dedupStub)
|
||||
|
||||
// " 会议 " and "会议" collapse to one query; the duplicate must not double the
|
||||
// requests or the rows.
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data botFanoutResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" {
|
||||
t.Fatalf("dedup failed: %+v", envelope.Data.Queries)
|
||||
}
|
||||
for _, bot := range envelope.Data.Bots {
|
||||
if bot.MatchedQuery != "会议" {
|
||||
t.Fatalf("matched_query fidelity: %+v", bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutConcurrencyCap(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
|
||||
var inFlight, peak int32
|
||||
stub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
stub.Reusable = true
|
||||
stub.OnMatch = func(req *http.Request) {
|
||||
cur := atomic.AddInt32(&inFlight, 1)
|
||||
defer atomic.AddInt32(&inFlight, -1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&peak)
|
||||
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
registry.Register(stub)
|
||||
|
||||
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if peak > fanoutConcurrency {
|
||||
t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency)
|
||||
}
|
||||
if peak < 2 {
|
||||
t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutPanicFailsBatch(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
panicCause := errors.New("synthetic test panic")
|
||||
|
||||
boom := botSearchStub(botSearchURL, "")
|
||||
boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }
|
||||
boom.OnMatch = func(req *http.Request) { panic(panicCause) }
|
||||
registry.Register(boom)
|
||||
|
||||
okStub := botSearchStub(botSearchURL, "")
|
||||
okStub.Reusable = true
|
||||
registry.Register(okStub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("a panicking query must fail the batch")
|
||||
}
|
||||
if !errors.Is(err, panicCause) {
|
||||
t.Fatalf("panic cause must be preserved: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
|
||||
}
|
||||
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
|
||||
if strings.Contains(stderr.String(), marker) {
|
||||
t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: botSearchURL,
|
||||
Reusable: true,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"reason": "boom"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("every query failing must surface as a command error")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("expected a typed problem, got %T: %v", err, err)
|
||||
}
|
||||
// The first failure's upstream status and the all-failed mode must both survive,
|
||||
// so a caller can classify instead of seeing a generic internal error.
|
||||
for _, want := range []string{"500", "all 2 queries failed"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("message must contain %q; got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
|
||||
broken := botSearchStub(botSearchURL, "")
|
||||
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
|
||||
broken.Status = 500
|
||||
broken.Body = map[string]interface{}{"reason": "boom"}
|
||||
registry.Register(broken)
|
||||
|
||||
okStub := botSearchStub(botSearchURL, "")
|
||||
okStub.Reusable = true
|
||||
registry.Register(okStub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("one failing query must not fail the batch: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data botFanoutResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
const wantNotice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
// Assert the notice itself, not just that some row survived: the surviving
|
||||
// query's server remark has to reach the caller both at the top level and in
|
||||
// its own sidecar entry.
|
||||
if envelope.Data.Notice != wantNotice {
|
||||
t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice)
|
||||
}
|
||||
if len(envelope.Data.Queries) != 2 {
|
||||
t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries)
|
||||
}
|
||||
if envelope.Data.Queries[0].Notice != wantNotice {
|
||||
t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice)
|
||||
}
|
||||
if envelope.Data.Queries[0].Error != "" {
|
||||
t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error)
|
||||
}
|
||||
if !strings.Contains(envelope.Data.Queries[1].Error, "500") {
|
||||
t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error)
|
||||
}
|
||||
// Only the surviving query contributes rows.
|
||||
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" {
|
||||
t.Fatalf("bots: %+v", envelope.Data.Bots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL, "")
|
||||
stub.Reusable = true
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_query") {
|
||||
t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String())
|
||||
}
|
||||
// csv is in the summary format set, so the batch counters belong on stderr.
|
||||
if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") {
|
||||
t.Errorf("stderr summary must report the batch counters: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "total bots") {
|
||||
t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL, "")
|
||||
stub.Reusable = true
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
// ndjson is a machine format outside the summary set: every stdout line must
|
||||
// parse, and the counters must not be mixed in.
|
||||
for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
t.Fatalf("stdout line %d is not JSON: %q", i, line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stderr.String(), "queries,") {
|
||||
t.Errorf("ndjson must not emit the summary line: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotFanoutCancelledSchedulingFailsQueuedQueries drives the real command so
|
||||
// the scheduler inside executeBotSearchFanout — not just runOneBotQuery — sees
|
||||
// the cancellation. Queueing more keywords than fanoutConcurrency while every
|
||||
// worker is parked keeps all semaphore slots held, so the queued keywords can
|
||||
// only leave the loop through its ctx.Done() branch.
|
||||
func TestBotFanoutCancelledSchedulingFailsQueuedQueries(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
started := make(chan struct{})
|
||||
var once sync.Once
|
||||
stub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
stub.Reusable = true
|
||||
stub.OnMatch = func(*http.Request) {
|
||||
once.Do(func() { close(started) })
|
||||
<-ctx.Done() // hold the slot so later keywords must queue on the semaphore
|
||||
}
|
||||
registry.Register(stub)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second): // never leave the workers parked
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
|
||||
queries := make([]string, 0, fanoutConcurrency+3)
|
||||
for i := 0; i < fanoutConcurrency+3; i++ {
|
||||
queries = append(queries, fmt.Sprintf("q%d", i))
|
||||
}
|
||||
|
||||
err := mountAndRunContext(t, ctx, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("a cancelled batch must surface as a command error")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation cause must be preserved: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotFanoutCancelledContextShortCircuitsBeforeRequest pins the other half:
|
||||
// a queued worker must fail on the pre-check instead of issuing its request.
|
||||
func TestBotFanoutCancelledContextShortCircuitsBeforeRequest(t *testing.T) {
|
||||
results := make([]botFanoutResult, 0, 2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for i, q := range []string{"会议", "日报"} {
|
||||
results = append(results, runOneBotQuery(ctx, nil, i, q, nil))
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.ErrMsg == "" {
|
||||
t.Fatalf("a cancelled context must short-circuit before the request: %+v", r)
|
||||
}
|
||||
}
|
||||
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err == nil {
|
||||
t.Fatal("all queries cancelled must surface as an error")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation cause must be preserved: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议")
|
||||
setBotSearchFlag(t, cmd, "chat-ids", "oc_a")
|
||||
setBotSearchFlag(t, cmd, "has-chatted", "true")
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
|
||||
raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
var preview struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body struct {
|
||||
Query string `json:"query"`
|
||||
Filter *struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
HasChatter bool `json:"has_chatter"`
|
||||
} `json:"filter"`
|
||||
} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &preview); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, raw)
|
||||
}
|
||||
|
||||
// Deduped, so the repeated keyword previews once — the preview has to match
|
||||
// the requests Execute would actually issue.
|
||||
if len(preview.API) != 2 {
|
||||
t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw)
|
||||
}
|
||||
seen := make([]string, 0, len(preview.API))
|
||||
for i, call := range preview.API {
|
||||
if call.Method != "POST" || call.URL != botSearchURL {
|
||||
t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL)
|
||||
}
|
||||
if call.Params["page_size"] != float64(20) {
|
||||
t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"])
|
||||
}
|
||||
if _, ok := call.Params["page_token"]; ok {
|
||||
t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params)
|
||||
}
|
||||
// The filter rides along with every keyword, not just the first.
|
||||
if call.Body.Filter == nil || !call.Body.Filter.HasChatter ||
|
||||
len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" {
|
||||
t.Errorf("api[%d] filter: %+v", i, call.Body.Filter)
|
||||
}
|
||||
seen = append(seen, call.Body.Query)
|
||||
}
|
||||
if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) {
|
||||
t.Errorf("previewed keywords: got %v, want [会议 日报]", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// The summary counts how many queries failed but never says which or why, and
|
||||
// only json carries queries[].error. Without a per-query line on stderr an agent
|
||||
// reading csv sees "1 failed" and cannot recover the keyword or the reason.
|
||||
func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) {
|
||||
for _, format := range []string{"csv", "table", "pretty", "ndjson"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
broken := botSearchStub(botSearchURL, "")
|
||||
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
|
||||
broken.Status = 500
|
||||
broken.Body = map[string]interface{}{"reason": "boom"}
|
||||
registry.Register(broken)
|
||||
okStub := botSearchStub(botSearchURL, "")
|
||||
okStub.Reusable = true
|
||||
registry.Register(okStub)
|
||||
|
||||
if err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("one failing query must not fail the batch: %v", err)
|
||||
}
|
||||
for _, want := range []string{"日报", "500"} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s",
|
||||
format, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
724
shortcuts/contact/contact_search_bot_test.go
Normal file
724
shortcuts/contact/contact_search_bot_test.go
Normal file
@@ -0,0 +1,724 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newBotSearchTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("chat-ids", "", "")
|
||||
cmd.Flags().Bool("has-chatted", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().String("queries", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func botSearchDefaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_self",
|
||||
}
|
||||
}
|
||||
|
||||
func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) {
|
||||
t.Helper()
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%q: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
// assertBotSearchValidationParams covers the errors that name several flags via
|
||||
// WithParams; those leave the single Param empty on purpose, so an agent reading
|
||||
// the envelope sees every flag that could satisfy the requirement.
|
||||
func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem: %+v ok=%v", problem, ok)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
got := make([]string, 0, len(validationErr.Params))
|
||||
for _, p := range validationErr.Params {
|
||||
if p.Reason == "" {
|
||||
t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name)
|
||||
}
|
||||
got = append(got, p.Name)
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(wantParams) {
|
||||
t.Fatalf("params: got %v, want %v", got, wantParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBotSearchErrors(t *testing.T) {
|
||||
chatIDs := make([]string, 101)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = fmt.Sprintf("oc_%03d", i)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
wantParams []string // set instead of wantParam when the error names several flags
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "keyword missing",
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
|
||||
},
|
||||
{
|
||||
name: "query over 50 characters",
|
||||
flags: map[string]string{"query": strings.Repeat("中", 51)},
|
||||
wantParam: "--query",
|
||||
wantMessage: "--query: length must be between 1 and 50 characters",
|
||||
},
|
||||
{
|
||||
name: "chat ids parse empty",
|
||||
flags: map[string]string{"query": "x", "chat-ids": " , , "},
|
||||
wantParam: "--chat-ids",
|
||||
wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')",
|
||||
},
|
||||
{
|
||||
name: "over 100 chat ids",
|
||||
flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")},
|
||||
wantParam: "--chat-ids",
|
||||
wantMessage: "--chat-ids: must be at most 100 entries",
|
||||
},
|
||||
{
|
||||
name: "invalid chat id",
|
||||
flags: map[string]string{"query": "x", "chat-ids": "bad"},
|
||||
wantParam: "--chat-ids",
|
||||
wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)",
|
||||
},
|
||||
{
|
||||
// With a keyword present the keyword errors win, exactly as +search-user
|
||||
// orders them; the =false check must not be hoisted above these.
|
||||
name: "mutually exclusive keywords outrank has chatted false",
|
||||
flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"},
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "--query and --queries are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "query length outranks has chatted false",
|
||||
flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"},
|
||||
wantParam: "--query",
|
||||
wantMessage: "--query: length must be between 1 and 50 characters",
|
||||
},
|
||||
{
|
||||
// With no keyword at all the explicit =false is the more specific mistake,
|
||||
// so it wins over the missing-keyword error rather than costing a second
|
||||
// round trip. Matches which error +search-user reports first.
|
||||
name: "has chatted false without a keyword",
|
||||
flags: map[string]string{"has-chatted": "false"},
|
||||
wantParam: "--has-chatted",
|
||||
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
|
||||
},
|
||||
{
|
||||
name: "has chatted false",
|
||||
flags: map[string]string{"query": "x", "has-chatted": "false"},
|
||||
wantParam: "--has-chatted",
|
||||
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
|
||||
},
|
||||
{
|
||||
name: "page size below one",
|
||||
flags: map[string]string{"query": "x", "page-size": "0"},
|
||||
wantParam: "--page-size",
|
||||
wantMessage: "--page-size: must be between 1 and 30",
|
||||
},
|
||||
{
|
||||
name: "page size over 30",
|
||||
flags: map[string]string{"query": "x", "page-size": "31"},
|
||||
wantParam: "--page-size",
|
||||
wantMessage: "--page-size: must be between 1 and 30",
|
||||
},
|
||||
{
|
||||
name: "chat ids without a keyword",
|
||||
flags: map[string]string{"chat-ids": "oc_a"},
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
|
||||
},
|
||||
{
|
||||
name: "has chatted without a keyword",
|
||||
flags: map[string]string{"has-chatted": "true"},
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
for name, value := range tt.flags {
|
||||
setBotSearchFlag(t, cmd, name, value)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
err := validateBotSearch(runtime)
|
||||
if len(tt.wantParams) > 0 {
|
||||
assertBotSearchValidationParams(t, err, tt.wantParams)
|
||||
} else {
|
||||
assertBotSearchValidationProblem(t, err, tt.wantParam)
|
||||
}
|
||||
if err.Error() != tt.wantMessage {
|
||||
t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBotSearchPassingCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
}{
|
||||
{name: "query only", flags: map[string]string{"query": "x"}},
|
||||
{name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}},
|
||||
{name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}},
|
||||
{name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}},
|
||||
{name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}},
|
||||
// An explicitly blank string flag reads as "no filter", matching how
|
||||
// +search-user treats --user-ids / --queries. Only a non-blank value that
|
||||
// parses to zero entries is an error.
|
||||
{name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}},
|
||||
{name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}},
|
||||
// Duplicates collapse before the cap is checked, so 101 copies of one chat
|
||||
// is one entry — matching how --user-ids is resolved for +search-user.
|
||||
{name: "duplicate chat ids collapse under the cap", flags: map[string]string{
|
||||
"query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","),
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
for name, value := range tt.flags {
|
||||
setBotSearchFlag(t, cmd, name, value)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
if err := validateBotSearch(runtime); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBotSearchQueryRuneBoundary(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
query string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "50 CJK characters", query: strings.Repeat("中", 50)},
|
||||
{name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "query", tt.query)
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
err := validateBotSearch(runtime)
|
||||
if tt.wantError {
|
||||
assertBotSearchValidationProblem(t, err, "--query")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBotSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantJSON string
|
||||
}{
|
||||
{name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`},
|
||||
{name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
|
||||
{name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
|
||||
{name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`},
|
||||
{name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`},
|
||||
// A blank --chat-ids must not materialize an empty filter object.
|
||||
{name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`},
|
||||
// Deduped after normalization, so a repeated id and a URL naming the same
|
||||
// chat both collapse into one entry instead of burning the server's quota.
|
||||
{name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
|
||||
{name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
for name, value := range tt.flags {
|
||||
setBotSearchFlag(t, cmd, name, value)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
body, err := buildBotSearchBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("build body: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
if string(raw) != tt.wantJSON {
|
||||
t.Fatalf("body: got %s, want %s", raw, tt.wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBotDisplayInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantName string
|
||||
wantDescription string
|
||||
wantSegments []string
|
||||
}{
|
||||
// Whole name highlighted, description on line two.
|
||||
{name: "whole name highlighted", raw: "<h>甲乙丙</h>\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}},
|
||||
// Two highlighted runs split by a plain character: stripping tags has to
|
||||
// rejoin them into one name.
|
||||
{name: "two highlighted runs", raw: "<h>甲乙</h>丁<h>丙</h>\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}},
|
||||
// Highlight at the end plus a trailing newline: line two exists but is empty.
|
||||
{name: "trailing newline empty description", raw: "戊己的<h>庚辛</h>\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
|
||||
// Single highlighted character in the middle of the name.
|
||||
{name: "mid-name highlight", raw: "壬癸<h>子</h>丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}},
|
||||
{name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}},
|
||||
{name: "html entities", raw: "<h>Lark</h>部门成员&仓库\n来自飞书多维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
|
||||
{name: "html entity in highlight", raw: "名称<h>&</h>工具", wantName: "名称&工具", wantSegments: []string{"&"}},
|
||||
{name: "empty", raw: "", wantSegments: []string{}},
|
||||
{name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}},
|
||||
// A blank first line must not make the description echo the name back and
|
||||
// swallow the real description on the line after it.
|
||||
{name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}},
|
||||
{name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}},
|
||||
// A highlight with no text carries nothing; an empty match segment is junk
|
||||
// in the envelope. Which line the name comes from is left unchanged.
|
||||
{name: "empty highlight yields no segment", raw: "<h></h>\n简介", wantName: "简介", wantSegments: []string{}},
|
||||
// The non-greedy pattern pairs a stray `<h>` with the next `</h>`, so the
|
||||
// capture can carry a tag the name and description already dropped.
|
||||
{name: "nested highlight", raw: "<h>甲<h>乙</h></h>\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{"甲乙"}},
|
||||
{name: "dangling open tag", raw: "<h><h>甲</h>\n简介", wantName: "甲", wantDescription: "简介", wantSegments: []string{"甲"}},
|
||||
{name: "unclosed highlight", raw: "<h>甲乙\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{}},
|
||||
// A literal `<h>` in a name arrives escaped, so it must survive: tags are
|
||||
// stripped before unescaping. Swapping that order eats the name's own text.
|
||||
{name: "escaped angle brackets are name text", raw: "名称<h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
|
||||
{name: "escaped angle brackets inside a highlight", raw: "<h>名称<h></h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{"名称<h>"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name, description, segments := parseBotDisplayInfo(tt.raw)
|
||||
if name != tt.wantName || description != tt.wantDescription {
|
||||
t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription)
|
||||
}
|
||||
if segments == nil {
|
||||
t.Fatal("match segments must be an empty slice, not nil")
|
||||
}
|
||||
if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) {
|
||||
t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBotsMapsEveryField(t *testing.T) {
|
||||
data := &botSearchAPIData{Items: []botSearchAPIItem{
|
||||
{
|
||||
ID: "ou_with_chat",
|
||||
DisplayInfo: "<h>甲乙丙</h>\n一句话简介",
|
||||
MetaData: botSearchAPIMeta{
|
||||
TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "ou_without_chat",
|
||||
DisplayInfo: "",
|
||||
MetaData: botSearchAPIMeta{TenantID: "1"},
|
||||
},
|
||||
}}
|
||||
|
||||
bots := projectBots(data)
|
||||
if len(bots) != 2 {
|
||||
t.Fatalf("bots: got %d, want 2", len(bots))
|
||||
}
|
||||
first := bots[0]
|
||||
if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" ||
|
||||
first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" ||
|
||||
fmt.Sprint(first.MatchSegments) != "[甲乙丙]" {
|
||||
t.Fatalf("first bot mapping: %+v", first)
|
||||
}
|
||||
second := bots[1]
|
||||
if second.Name != "" || second.ChatID != "" {
|
||||
t.Fatalf("empty source fields must stay empty: %+v", second)
|
||||
}
|
||||
raw, err := json.Marshal(searchBotResponse{Bots: bots})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"chat_id":""`) {
|
||||
t.Fatalf("empty chat_id must still be emitted: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"name":""`) {
|
||||
t.Fatalf("empty name must not fall back to open_id: %s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"has_chatted"`) {
|
||||
t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBotsEmptySerializesAsArray(t *testing.T) {
|
||||
bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}})
|
||||
if bots == nil {
|
||||
t.Fatal("bots must be an empty slice, not nil")
|
||||
}
|
||||
raw, err := json.Marshal(searchBotResponse{Bots: bots})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
if string(raw) != `{"bots":[],"has_more":false}` {
|
||||
t.Fatalf("response: got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func botSearchStub(url string, pageToken string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: url,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"has_more": true,
|
||||
"page_token": pageToken,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_bot",
|
||||
"display_info": "<h>甲乙丙</h>\n一句话简介",
|
||||
"meta_data": map[string]interface{}{
|
||||
"tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out")
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
|
||||
"--page-size", "25", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("request body: %v", err)
|
||||
}
|
||||
if requestBody["query"] != "甲乙" {
|
||||
t.Fatalf("request query: got %v", requestBody["query"])
|
||||
}
|
||||
filter, ok := requestBody["filter"].(map[string]interface{})
|
||||
if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" {
|
||||
t.Fatalf("request filter: %#v", requestBody["filter"])
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data searchBotResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore {
|
||||
t.Fatalf("response pass-through: %+v", envelope.Data)
|
||||
}
|
||||
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" {
|
||||
t.Fatalf("bots: %+v", envelope.Data.Bots)
|
||||
}
|
||||
registry.Verify(t)
|
||||
}
|
||||
|
||||
func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
// The stub returns a token; the envelope must still not carry one, matching
|
||||
// +search-user, which decodes page_token and drops it.
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v", err)
|
||||
}
|
||||
data := envelope["data"].(map[string]interface{})
|
||||
if _, ok := data["page_token"]; ok {
|
||||
t.Fatalf("page_token must never be surfaced: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} {
|
||||
if !strings.Contains(stdout.String(), column) {
|
||||
t.Errorf("pretty output missing %q: %s", column, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} {
|
||||
if strings.Contains(stdout.String(), genericField) {
|
||||
t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
|
||||
}
|
||||
}
|
||||
// pretty stdout carries rows only, so stderr has to carry both the server
|
||||
// notice and the pagination hint.
|
||||
for _, want := range []string{
|
||||
"notice: The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
|
||||
} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("pretty stderr missing %q: %q", want, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
|
||||
if !strings.Contains(stdout.String(), field) {
|
||||
t.Errorf("table output missing %q: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
// table stdout carries rows only, so stderr has to carry both the server
|
||||
// notice and the pagination hint.
|
||||
for _, want := range []string{
|
||||
"notice: The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
|
||||
} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("table stderr missing %q: %q", want, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The old name and assertion here pinned a bug: csv and ndjson were the two
|
||||
// formats that carried neither has_more in stdout nor a hint on stderr, so a
|
||||
// machine caller read a truncated result as the whole answer. stdout stays
|
||||
// data-only; the truncation signal belongs on stderr for every format whose
|
||||
// stdout has no envelope.
|
||||
func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) {
|
||||
for _, format := range []string{"csv", "ndjson"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
|
||||
if !strings.Contains(stdout.String(), field) {
|
||||
t.Errorf("%s output missing %q: %s", format, field, stdout.String())
|
||||
}
|
||||
}
|
||||
// stdout must stay data-only, so both the notice and the truncation
|
||||
// signal have to arrive on stderr.
|
||||
for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "more matches exist") {
|
||||
t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchPrettyEmptyResult(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: botSearchURL + "?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No bots found.") {
|
||||
t.Fatalf("pretty output: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchDryRunMirrorsRequest(t *testing.T) {
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted",
|
||||
"--page-size", "25", "--dry-run", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body botSearchAPIRequest `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("dry-run JSON: %v", err)
|
||||
}
|
||||
if len(envelope.Data.API) != 1 {
|
||||
t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API))
|
||||
}
|
||||
call := envelope.Data.API[0]
|
||||
if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) {
|
||||
t.Fatalf("dry-run call: %+v", call)
|
||||
}
|
||||
if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter {
|
||||
t.Fatalf("dry-run body: %+v", call.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) {
|
||||
_, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}})
|
||||
if err == nil {
|
||||
t.Fatal("expected marshal failure")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem: %+v, ok=%v", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the json envelope carries data.notice. If the other formats dropped it
|
||||
// silently, a caller would read a truncated or incomplete result as a complete
|
||||
// one, so every non-json format has to surface it on stderr instead.
|
||||
func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", ""))
|
||||
if err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), notice) {
|
||||
if format != "json" {
|
||||
t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(stderr.String(), notice) {
|
||||
t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s",
|
||||
format, stdout.String(), stderr.String())
|
||||
}
|
||||
// stdout stays pipe-clean: the notice must not be mixed into the rows.
|
||||
if format == "csv" && strings.Contains(stdout.String(), "notice") {
|
||||
t.Fatalf("csv stdout must stay data-only: %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// has_more is the server saying "this is not the whole answer". Only the json
|
||||
// envelope carries it, so every other format has to say so on stderr or a machine
|
||||
// caller silently treats a truncated result as complete.
|
||||
func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) {
|
||||
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor"))
|
||||
if err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if format == "json" {
|
||||
if !strings.Contains(stdout.String(), `"has_more": true`) {
|
||||
t.Fatalf("json must carry has_more in the envelope: %s", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "more matches exist") {
|
||||
t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s",
|
||||
format, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -550,6 +550,13 @@ func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it
|
||||
// with the given args. Mirrors the pattern used in other shortcut packages.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
|
||||
}
|
||||
|
||||
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
|
||||
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
|
||||
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "contact"}
|
||||
s.Mount(parent, f)
|
||||
@@ -559,7 +566,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
return parent.ExecuteContext(ctx)
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ContactSearchUser,
|
||||
ContactSearchBot,
|
||||
ContactGetUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ const defaultLocateDocLimit = 10
|
||||
// with `drive file.comments create_v2` against a fresh docx.
|
||||
const maxCommentTotalRunes = 10000
|
||||
|
||||
// maxCommentReplyElements is the element-count cap declared ONLY by the
|
||||
// reply-create endpoint (POST .../comments/:comment_id/replies), whose
|
||||
// content.elements schema says "最大元素个数为100". It is enforced only by
|
||||
// +add-reply. create_v2 (+add-comment) and the reply-update endpoint
|
||||
// (+update-reply) do not declare this cap, so their inputs are not capped
|
||||
// here — see the shared parseCommentReplyElements, which stays uncapped.
|
||||
const maxCommentReplyElements = 100
|
||||
|
||||
// The file comment API treats supported Drive file comments as full-file
|
||||
// comments in the UI, but currently rejects an empty anchor.block_id for file
|
||||
// targets. TODO: remove this placeholder after the API accepts omitting
|
||||
|
||||
@@ -918,6 +918,27 @@ func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// create_v2 (+add-comment) uses reply_elements, which does NOT declare the
|
||||
// 100-element cap that the reply-create endpoint does; +add-comment must not
|
||||
// reject >100 elements locally.
|
||||
func TestDriveAddCommentDoesNotCapElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 101)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--full-comment",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("+add-comment must not cap element count locally, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
|
||||
212
shortcuts/drive/drive_add_reply.go
Normal file
212
shortcuts/drive/drive_add_reply.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveAddReplyOp = driveCommentOp{
|
||||
Label: "comment reply",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveAddReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
|
||||
}
|
||||
|
||||
func (s driveAddReplySpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"elements": driveReplyV1Elements(s.ReplyElements),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveAddReply replies to an existing comment through the Drive comment
|
||||
// reply create API (POST .../comments/:comment_id/replies), while accepting
|
||||
// Wiki URLs/tokens and resolving them to the underlying object.
|
||||
//
|
||||
// Note: the documented alternative — POST .../comments with comment_id in the
|
||||
// body ("如填写,则视为回复已有评论") — does NOT reply on docx in practice; it
|
||||
// silently creates a new standalone comment instead.
|
||||
var DriveAddReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-reply",
|
||||
Description: "Add a reply to an existing comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:create"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveAddReplyOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID to reply to (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
),
|
||||
Tips: []string{
|
||||
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
"Whole-document comments (is_whole=true) and solved comments (is_solved=true) do not accept replies; check the comment state via `drive +list-comments` first.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveAddReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveAddReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveAddReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveAddReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveAddReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Adding reply to comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
)
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extra := map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"created": true,
|
||||
}
|
||||
if replyID := extractDriveCreatedReplyID(data); replyID != "" {
|
||||
extra["reply_id"] = replyID
|
||||
}
|
||||
runtime.Out(driveCommentTargetOutput(target, extra), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveAddReplySpec(runtime *common.RuntimeContext) (driveAddReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveAddReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveAddReplySpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveAddReplySpec{}, err
|
||||
}
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return driveAddReplySpec{}, err
|
||||
}
|
||||
// The reply-create endpoint documents a 100-element cap on content.elements;
|
||||
// reject over-cap input locally instead of surfacing the opaque [1069302].
|
||||
if len(replyElements) > maxCommentReplyElements {
|
||||
return driveAddReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--content has %d elements; the reply endpoint caps content.elements at %d", len(replyElements), maxCommentReplyElements).
|
||||
WithParam("--content")
|
||||
}
|
||||
return driveAddReplySpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
ReplyElements: replyElements,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// driveReplyV1Elements converts the simplified +add-comment reply element form
|
||||
// (text / mention_user / link) to the Drive v1 comment create wire form
|
||||
// (text_run / person / docs_link).
|
||||
func driveReplyV1Elements(replyElements []map[string]interface{}) []map[string]interface{} {
|
||||
elements := make([]map[string]interface{}, 0, len(replyElements))
|
||||
for _, element := range replyElements {
|
||||
switch common.GetString(element, "type") {
|
||||
case "text":
|
||||
elements = append(elements, map[string]interface{}{
|
||||
"type": "text_run",
|
||||
"text_run": map[string]interface{}{"text": common.GetString(element, "text")},
|
||||
})
|
||||
case "mention_user":
|
||||
elements = append(elements, map[string]interface{}{
|
||||
"type": "person",
|
||||
"person": map[string]interface{}{"user_id": common.GetString(element, "mention_user")},
|
||||
})
|
||||
case "link":
|
||||
elements = append(elements, map[string]interface{}{
|
||||
"type": "docs_link",
|
||||
"docs_link": map[string]interface{}{"url": common.GetString(element, "link")},
|
||||
})
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
// extractDriveCreatedReplyID pulls the created reply ID out of the reply
|
||||
// create response, tolerating the shapes the API family uses: a top-level
|
||||
// reply_id, a nested reply object, or a reply_list wrapper.
|
||||
func extractDriveCreatedReplyID(data map[string]interface{}) string {
|
||||
if replyID := common.GetString(data, "reply_id"); replyID != "" {
|
||||
return replyID
|
||||
}
|
||||
if reply := common.GetMap(data, "reply"); reply != nil {
|
||||
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
|
||||
return replyID
|
||||
}
|
||||
}
|
||||
replyList := common.GetMap(data, "reply_list")
|
||||
if replyList == nil {
|
||||
return ""
|
||||
}
|
||||
for _, item := range common.GetSlice(replyList, "replies") {
|
||||
reply, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
|
||||
return replyID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildDriveAddReplyDryRun(spec driveAddReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> add reply to comment").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
|
||||
Desc("[2] Add reply to comment on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: add reply to comment").
|
||||
POST("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
393
shortcuts/drive/drive_add_reply_test.go
Normal file
393
shortcuts/drive/drive_add_reply_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveReplyV1Elements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
elements, err := parseCommentReplyElements(`[
|
||||
{"type":"text","text":"a<b"},
|
||||
{"type":"mention_user","mention_user":"ou_123"},
|
||||
{"type":"link","link":"https://example.com"}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := driveReplyV1Elements(elements)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0]["type"] != "text_run" {
|
||||
t.Fatalf("elements[0].type = %#v, want text_run", got[0]["type"])
|
||||
}
|
||||
textRun, ok := got[0]["text_run"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("elements[0].text_run is %T, want map", got[0]["text_run"])
|
||||
}
|
||||
if textRun["text"] != "a<b" {
|
||||
t.Fatalf("elements[0].text_run.text = %#v, want escaped a<b", textRun["text"])
|
||||
}
|
||||
person, ok := got[1]["person"].(map[string]interface{})
|
||||
if !ok || got[1]["type"] != "person" {
|
||||
t.Fatalf("elements[1] = %#v, want person element", got[1])
|
||||
}
|
||||
if person["user_id"] != "ou_123" {
|
||||
t.Fatalf("elements[1].person.user_id = %#v, want ou_123", person["user_id"])
|
||||
}
|
||||
docsLink, ok := got[2]["docs_link"].(map[string]interface{})
|
||||
if !ok || got[2]["type"] != "docs_link" {
|
||||
t.Fatalf("elements[2] = %#v, want docs_link element", got[2])
|
||||
}
|
||||
if docsLink["url"] != "https://example.com" {
|
||||
t.Fatalf("elements[2].docs_link.url = %#v, want https://example.com", docsLink["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"reply": map[string]interface{}{
|
||||
"reply_id": "reply_9",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"收到,我来处理"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if _, ok := body["comment_id"]; ok {
|
||||
t.Fatalf("request body must not carry comment_id (it rides in the URL path): %v", body)
|
||||
}
|
||||
content := mustMapValue(t, body["content"], "request.content")
|
||||
elements := mustSliceValue(t, content["elements"], "request.content.elements")
|
||||
element := mustMapValue(t, elements[0], "request.content.elements[0]")
|
||||
if got := mustStringField(t, element, "type", "request.content.elements[0].type"); got != "text_run" {
|
||||
t.Fatalf("request element type = %q, want text_run", got)
|
||||
}
|
||||
elementText := mustMapValue(t, element["text_run"], "request.content.elements[0].text_run")
|
||||
if got := mustStringField(t, elementText, "text", "request.content.elements[0].text_run.text"); got != "收到,我来处理" {
|
||||
t.Fatalf("text_run.text = %q, want 收到,我来处理", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_9" {
|
||||
t.Fatalf("reply_id = %q, want reply_9", got)
|
||||
}
|
||||
if got := data["created"]; got != true {
|
||||
t.Fatalf("created = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyExecuteWikiResolvesToDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply from wiki"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWiki" {
|
||||
t.Fatalf("file_token = %q, want docxFromWiki", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
if _, ok := data["reply_id"]; ok {
|
||||
t.Fatalf("reply_id should be omitted when the response carries none: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyRejectsUnsupportedTargets(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported --url resource type "folder"`) {
|
||||
t.Fatalf("expected unsupported-type error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestDriveAddReplyWikiResolvesToUnsupported(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "mindnote",
|
||||
"obj_token": "mindnoteFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote", but comment reply only supports`) {
|
||||
t.Fatalf("expected wiki-resolution error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestExtractDriveCreatedReplyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "nil data", data: nil, want: ""},
|
||||
{name: "top-level reply_id", data: map[string]interface{}{"reply_id": "r1"}, want: "r1"},
|
||||
{name: "nested reply object", data: map[string]interface{}{"reply": map[string]interface{}{"reply_id": "r2"}}, want: "r2"},
|
||||
{name: "nested reply without id falls through", data: map[string]interface{}{"reply": map[string]interface{}{}}, want: ""},
|
||||
{
|
||||
name: "reply_list wrapper",
|
||||
data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{
|
||||
"not-a-map",
|
||||
map[string]interface{}{"reply_id": ""},
|
||||
map[string]interface{}{"reply_id": "r3"},
|
||||
}}},
|
||||
want: "r3",
|
||||
},
|
||||
{name: "reply_list without match", data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{map[string]interface{}{}}}}, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := extractDriveCreatedReplyID(tt.data); got != tt.want {
|
||||
t.Fatalf("extractDriveCreatedReplyID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyRejectsUnsafeCommentID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected comment-id validation error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--comment-id")
|
||||
}
|
||||
|
||||
func TestDriveAddReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "comment not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "comment not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
|
||||
t.Fatalf("api[1].url = %q, want replies URL with comment ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyInvalidContent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `not-json`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") {
|
||||
t.Fatalf("expected content JSON error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyRejectsTooManyElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 101)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "caps content.elements at 100") {
|
||||
t.Fatalf("expected 100-element cap error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--content")
|
||||
}
|
||||
|
||||
func TestDriveAddReplyAcceptsMaxElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 100)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("100 elements should be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
|
||||
t.Fatalf("api[0].url = %q, want reply create URL with comment ID", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if _, ok := body["comment_id"]; ok {
|
||||
t.Fatalf("api[0].body must not carry comment_id: %v", body)
|
||||
}
|
||||
content := mustMapValue(t, body["content"], "api[0].body.content")
|
||||
if _, ok := content["elements"]; !ok {
|
||||
t.Fatalf("api[0].body.content.elements missing: %v", body)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -13,72 +14,137 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// permApplyTypes is the authoritative list of type values the apply-permission
|
||||
// endpoint accepts for its required `type` query parameter.
|
||||
var permApplyTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "slides",
|
||||
type permApplyResourceKind struct {
|
||||
Type string
|
||||
Path string
|
||||
}
|
||||
|
||||
// permApplyURLMarkers maps document URL path markers to the `type` value the
|
||||
// apply-permission endpoint expects. Markers are disjoint strings (each begins
|
||||
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
|
||||
var permApplyURLMarkers = []struct {
|
||||
Marker string
|
||||
Type string
|
||||
}{
|
||||
{"/wiki/", "wiki"},
|
||||
{"/docx/", "docx"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnote/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/doc/", "doc"},
|
||||
// permApplyResourceKinds is the authoritative target contract for the
|
||||
// apply-permission endpoint: accepted types and their URL root paths.
|
||||
var permApplyResourceKinds = []permApplyResourceKind{
|
||||
{Type: "doc", Path: "/doc/"},
|
||||
{Type: "sheet", Path: "/sheets/"},
|
||||
{Type: "file", Path: "/file/"},
|
||||
{Type: "wiki", Path: "/wiki/"},
|
||||
{Type: "bitable", Path: "/base/"},
|
||||
{Type: "bitable", Path: "/bitable/"},
|
||||
{Type: "docx", Path: "/docx/"},
|
||||
{Type: "mindnote", Path: "/mindnote/"},
|
||||
{Type: "slides", Path: "/slides/"},
|
||||
{Type: "apps", Path: "/page/"},
|
||||
}
|
||||
|
||||
var permApplyTypes = func() []string {
|
||||
types := make([]string, 0, len(permApplyResourceKinds))
|
||||
seen := make(map[string]struct{}, len(permApplyResourceKinds))
|
||||
for _, resourceKind := range permApplyResourceKinds {
|
||||
if _, ok := seen[resourceKind.Type]; ok {
|
||||
continue
|
||||
}
|
||||
seen[resourceKind.Type] = struct{}{}
|
||||
types = append(types, resourceKind.Type)
|
||||
}
|
||||
return types
|
||||
}()
|
||||
|
||||
func permApplyTypeAllowed(docType string) bool {
|
||||
for _, allowedType := range permApplyTypes {
|
||||
if docType == allowedType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
|
||||
// value that may be either a bare token or a full document URL, plus an
|
||||
// optional explicit --type. Explicit --type wins over URL inference.
|
||||
// optional explicit --type. A URL's path and explicit --type must agree.
|
||||
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && !permApplyTypeAllowed(explicitType) {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(permApplyTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
for _, m := range permApplyURLMarkers {
|
||||
if tok, ok := extractURLToken(raw, m.Marker); ok {
|
||||
token = tok
|
||||
if explicitType == "" {
|
||||
docType = m.Type
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
ref, ok := parsePermApplyResourceURL(raw)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/, /page/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
token, docType = ref.Token, ref.Type
|
||||
if explicitType != "" && explicitType != docType {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
docType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
}
|
||||
|
||||
if explicitType != "" {
|
||||
docType = explicitType
|
||||
}
|
||||
|
||||
if docType == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(permApplyTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validatePermApplyToken(token); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, docType, nil
|
||||
}
|
||||
|
||||
func parsePermApplyResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
escapedPath := parsed.EscapedPath()
|
||||
for _, resourceKind := range permApplyResourceKinds {
|
||||
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
|
||||
continue
|
||||
}
|
||||
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
|
||||
if escapedToken == "" || strings.Contains(escapedToken, "/") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
token, err := url.PathUnescape(escapedToken)
|
||||
if err != nil || token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func validatePermApplyToken(token string) error {
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if token == "." || strings.Contains(token, "/") {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must be a non-dot single path segment",
|
||||
).WithParam("--token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DriveApplyPermission applies to the document owner for view or edit access
|
||||
// on behalf of the invoking user. Matches the open-apis endpoint
|
||||
// /open-apis/drive/v1/permissions/:token/members/apply.
|
||||
@@ -88,16 +154,19 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
|
||||
var DriveApplyPermission = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+apply-permission",
|
||||
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
|
||||
Description: "Apply to the owner for view or edit permission on a Drive resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:permission.member:apply"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "token", Desc: "target token or URL (docx/sheets/base/file/wiki/doc/mindnote/slides/page)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes},
|
||||
{Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}},
|
||||
{Name: "remark", Desc: "optional note shown on the request card sent to the owner"},
|
||||
},
|
||||
Tips: []string{
|
||||
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
@@ -109,7 +178,7 @@ var DriveApplyPermission = common.Shortcut{
|
||||
}
|
||||
body := buildPermApplyBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Apply to document owner for access").
|
||||
Desc("Apply to resource owner for access").
|
||||
POST("/open-apis/drive/v1/permissions/:token/members/apply").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(body).
|
||||
@@ -131,7 +200,7 @@ var DriveApplyPermission = common.Shortcut{
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return decoratePermApplyError(err)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
@@ -148,3 +217,34 @@ func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decoratePermApplyError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
guidance := permApplyErrorGuidance(problem.Code)
|
||||
if guidance == "" {
|
||||
return err
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
problem.Hint = guidance
|
||||
} else if !strings.Contains(problem.Hint, guidance) {
|
||||
problem.Hint += "; " + guidance
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func permApplyErrorGuidance(code int) string {
|
||||
switch code {
|
||||
case 1063006:
|
||||
return "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait for the daily quota to reset before retrying"
|
||||
case 1063007:
|
||||
return "this document does not accept a permission-apply request; verify the target and requested permission, or contact the owner directly"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -33,6 +35,18 @@ func TestResolvePermApplyTarget_BareTokenWithType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_BareTokenWithAppsType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolvePermApplyTarget("appBareToken", "apps")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "appBareToken" || docType != "apps" {
|
||||
t.Fatalf("got token=%q type=%q, want appBareToken/apps", token, docType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
@@ -50,6 +64,7 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
|
||||
{"mindnote", "https://example.feishu.cn/mindnote/mnTok444", "mnTok444", "mindnote"},
|
||||
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
|
||||
{"apps page", "https://example.feishu.cn/page/appMetaTok/?from=share", "appMetaTok", "apps"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
@@ -66,15 +81,100 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) {
|
||||
func TestResolvePermApplyTarget_RejectsMalformedPageURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Even though the URL marker is /docx/, an explicit --type wins.
|
||||
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/page/?from=share", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "could not infer token") {
|
||||
t.Fatalf("expected page token inference error, got token=%q type=%q error=%v", token, docType, err)
|
||||
}
|
||||
if token != "doxTok123" || docType != "wiki" {
|
||||
t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType)
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_RejectsAppsMarkerOutsidePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{
|
||||
name: "query",
|
||||
raw: "https://example.feishu.cn/share?redirect=/page/appMetaTok",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
raw: "https://example.feishu.cn/share#/page/appMetaTok",
|
||||
},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolvePermApplyTarget(tt.raw, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected URL path inference error, got token=%q type=%q", token, docType)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error category/subtype = %q/%q, want %q/%q",
|
||||
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--token" {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, "--token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_RejectsConflictingURLType(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts with URL path type") {
|
||||
t.Fatalf("expected URL type conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_RejectsUnsafeOrAmbiguousTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
type_ string
|
||||
}{
|
||||
{"bare traversal token", "..", "docx"},
|
||||
{"bare dot token", ".", "docx"},
|
||||
{"URL traversal token", "https://example.feishu.cn/docx/../victim", ""},
|
||||
{"marker outside resource root", "https://example.feishu.cn/share/docx/doxUnexpected", ""},
|
||||
{"encoded path separator", "https://example.feishu.cn/docx/doxTarget%2Fother", ""},
|
||||
{"encoded query separator", "https://example.feishu.cn/docx/doxTarget%3Fother", ""},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := resolvePermApplyTarget(tt.raw, tt.type_)
|
||||
if err == nil {
|
||||
t.Fatalf("resolvePermApplyTarget(%q, %q) unexpectedly succeeded", tt.raw, tt.type_)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--token" {
|
||||
t.Fatalf("error param = %q, want --token", validationErr.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +250,33 @@ func TestDriveApplyPermission_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_DryRunAcceptsAppsBareToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "appBareToken",
|
||||
"--type", "apps",
|
||||
"--perm", "edit",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v1/permissions/appBareToken/members/apply",
|
||||
`"apps"`,
|
||||
`"edit"`,
|
||||
`"appBareToken"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
// Stub URL includes "?type=docx" — the stub only matches when the request
|
||||
@@ -196,6 +323,11 @@ func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063007, "msg": "request not applicable",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server says requests are disabled"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -212,6 +344,18 @@ func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "not applicable") {
|
||||
t.Fatalf("expected surfaced server message, got: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeInvalidParameters || problem.Code != 1063007 {
|
||||
t.Fatalf("problem = %+v, want api/invalid_parameters code 1063007", problem)
|
||||
}
|
||||
for _, want := range []string{"server says requests are disabled", "does not accept a permission-apply request", "contact the owner"} {
|
||||
if !strings.Contains(problem.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, problem.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
|
||||
@@ -235,4 +379,17 @@ func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 1063006")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeRateLimit || problem.Code != 1063006 {
|
||||
t.Fatalf("problem = %+v, want api/rate_limit code 1063006", problem)
|
||||
}
|
||||
if problem.Retryable {
|
||||
t.Fatalf("problem.Retryable = true, want false for the daily per-document quota")
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "at most 5 times per day") {
|
||||
t.Fatalf("hint missing daily quota guidance: %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
175
shortcuts/drive/drive_batch_query_comments.go
Normal file
175
shortcuts/drive/drive_batch_query_comments.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// driveBatchQueryCommentsMaxIDs mirrors the server-side cap on comment_ids
|
||||
// per batch_query call.
|
||||
const driveBatchQueryCommentsMaxIDs = 100
|
||||
|
||||
var driveBatchQueryCommentsOp = driveCommentOp{
|
||||
Label: "comments batch query",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveBatchQueryCommentsSpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentIDs []string
|
||||
NeedReaction bool
|
||||
NeedRelation bool
|
||||
}
|
||||
|
||||
// RequestBody assembles the batch_query body for the resolved fileType.
|
||||
// need_relation is absent from the platform metadata for this endpoint but
|
||||
// honored live (same undocumented parameter +list-comments already uses);
|
||||
// only docx returns relation data, so it is sent for docx targets only.
|
||||
func (s driveBatchQueryCommentsSpec) RequestBody(fileType string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"comment_ids": s.CommentIDs,
|
||||
}
|
||||
if s.NeedReaction {
|
||||
body["need_reaction"] = true
|
||||
}
|
||||
if s.NeedRelation && fileType == "docx" {
|
||||
body["need_relation"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// DriveBatchQueryComments fetches comments by ID through the Drive comment
|
||||
// batch_query API, while accepting Wiki URLs/tokens and resolving them to the
|
||||
// underlying object.
|
||||
var DriveBatchQueryComments = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+batch-query-comments",
|
||||
Description: "Batch get comments by comment ID for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveBatchQueryCommentsOp),
|
||||
common.Flag{Name: "comment-ids", Type: "string_slice", Desc: fmt.Sprintf("comment IDs to fetch (comma-separated or repeated flag, max %d)", driveBatchQueryCommentsMaxIDs), Required: true},
|
||||
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
|
||||
common.Flag{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
|
||||
),
|
||||
Tips: []string{
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
"--comment-ids accepts comma-separated values and repeated flags, up to 100 IDs per call.",
|
||||
"--need-relation returns the docx comment anchor (items[].relation with the block position); see the lark-drive comment-location guide.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveBatchQueryCommentsSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveBatchQueryCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveBatchQueryCommentsDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveBatchQueryCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveBatchQueryCommentsOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Batch querying %d comment(s) in %s...\n", len(spec.CommentIDs), common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments/batch_query", validate.EncodePathSegment(target.FileToken))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(target.FileType),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := driveCommentItems(data)
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveBatchQueryCommentsSpec(runtime *common.RuntimeContext) (driveBatchQueryCommentsSpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveBatchQueryCommentsOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveBatchQueryCommentsSpec{}, err
|
||||
}
|
||||
ids, err := normalizeDriveCommentIDs(runtime.StrSlice("comment-ids"))
|
||||
if err != nil {
|
||||
return driveBatchQueryCommentsSpec{}, err
|
||||
}
|
||||
return driveBatchQueryCommentsSpec{
|
||||
Ref: ref,
|
||||
CommentIDs: ids,
|
||||
NeedReaction: runtime.Bool("need-reaction"),
|
||||
NeedRelation: runtime.Bool("need-relation"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeDriveCommentIDs(raw []string) ([]string, error) {
|
||||
ids := make([]string, 0, len(raw))
|
||||
for i, id := range raw {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids element #%d is empty", i+1).WithParam("--comment-ids")
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids must contain at least one comment ID").WithParam("--comment-ids")
|
||||
}
|
||||
if len(ids) > driveBatchQueryCommentsMaxIDs {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids accepts at most %d comment IDs per call (got %d)", driveBatchQueryCommentsMaxIDs, len(ids)).WithParam("--comment-ids")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func buildDriveBatchQueryCommentsDryRun(spec driveBatchQueryCommentsSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
// The wiki obj_type is unknown until step 1 resolves, so RequestBody
|
||||
// cannot decide the docx-only need_relation gate here; surface it as a
|
||||
// placeholder the same way +list-comments does.
|
||||
body := spec.RequestBody("<obj_type from step 1>")
|
||||
if spec.NeedRelation {
|
||||
body["need_relation"] = "<sent only when obj_type is docx>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> batch query comments").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/batch_query").
|
||||
Desc("[2] Batch query comments on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: batch query comments").
|
||||
POST("/open-apis/drive/v1/files/:file_token/comments/batch_query").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody(spec.Ref.Type)).
|
||||
Set("file_token", spec.Ref.Token)
|
||||
}
|
||||
560
shortcuts/drive/drive_batch_query_comments_test.go
Normal file
560
shortcuts/drive/drive_batch_query_comments_test.go
Normal file
@@ -0,0 +1,560 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestNormalizeDriveCommentIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := normalizeDriveCommentIDs([]string{" c1 ", "c2"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "c1" || got[1] != "c2" {
|
||||
t.Fatalf("normalizeDriveCommentIDs = %v, want [c1 c2]", got)
|
||||
}
|
||||
|
||||
if _, err := normalizeDriveCommentIDs(nil); err == nil || !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("expected at-least-one error, got %v", err)
|
||||
}
|
||||
if _, err := normalizeDriveCommentIDs([]string{"c1", " "}); err == nil || !strings.Contains(err.Error(), "element #2 is empty") {
|
||||
t.Fatalf("expected empty-element error, got %v", err)
|
||||
}
|
||||
|
||||
tooMany := make([]string, driveBatchQueryCommentsMaxIDs+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("c%d", i)
|
||||
}
|
||||
_, err = normalizeDriveCommentIDs(tooMany)
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 100") {
|
||||
t.Fatalf("expected max-IDs error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--comment-ids")
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"comment_id": "comment_1", "is_solved": false},
|
||||
{"comment_id": "comment_2", "is_solved": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1,comment_2",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
ids := mustSliceValue(t, body["comment_ids"], "request.comment_ids")
|
||||
if len(ids) != 2 || ids[0] != "comment_1" || ids[1] != "comment_2" {
|
||||
t.Fatalf("request comment_ids = %v, want [comment_1 comment_2]", ids)
|
||||
}
|
||||
if _, ok := body["need_reaction"]; ok {
|
||||
t.Fatalf("request should omit need_reaction by default: %v", body)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
|
||||
t.Fatalf("file_token = %q, want docxResource", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(2) {
|
||||
t.Fatalf("count = %#v, want 2", got)
|
||||
}
|
||||
if _, ok := data["wiki_token"]; ok {
|
||||
t.Fatalf("wiki_token should be omitted for direct targets: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteWikiWithReaction(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("token"); got != "wikiResource" {
|
||||
t.Errorf("wiki token = %q, want wikiResource", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "sheetFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "sheet" {
|
||||
t.Errorf("file_type = %q, want sheet", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-reaction",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["need_reaction"]; got != true {
|
||||
t.Fatalf("request need_reaction = %#v, want true", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "sheetFromWiki" {
|
||||
t.Fatalf("file_token = %q, want sheetFromWiki", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
|
||||
t.Fatalf("file_type = %q, want sheet", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteAppsPageURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/appsPageResource/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "apps" {
|
||||
t.Errorf("file_type = %q, want apps", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.feishu.cn/page/appsPageResource/",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "apps" {
|
||||
t.Fatalf("file_type = %q, want apps", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "appsPageResource" {
|
||||
t.Fatalf("file_token = %q, want appsPageResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteBaseURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/baseResource/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/base/baseResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("file_type = %q, want bitable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsWikiResolvesToUnsupported(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "mindnote",
|
||||
"obj_token": "mindnoteToken",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote"`) {
|
||||
t.Fatalf("expected wiki-resolution error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteBaseAliasType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/baseToken/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable (base alias normalized)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []map[string]interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--token", "baseToken",
|
||||
"--type", "base",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
args: []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--token", "docxResource",
|
||||
"--comment-ids", "comment_1",
|
||||
},
|
||||
wantErr: "mutually exclusive",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "blank comment id element",
|
||||
args: []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", " ",
|
||||
},
|
||||
wantErr: "element #1 is empty",
|
||||
wantParam: "--comment-ids",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "comment not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_404",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "comment not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1,comment_2",
|
||||
"--need-reaction",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/batch_query") {
|
||||
t.Fatalf("api[0].url = %q, want resolved batch_query URL", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if got := body["need_reaction"]; got != true {
|
||||
t.Fatalf("api[0].body.need_reaction = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
|
||||
t.Fatalf("api[1].method = %q, want POST", got)
|
||||
}
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
ids := mustSliceValue(t, body["comment_ids"], "api[1].body.comment_ids")
|
||||
if len(ids) != 1 || ids[0] != "comment_1" {
|
||||
t.Fatalf("api[1].body.comment_ids = %v, want [comment_1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsDryRunWikiNeedRelation(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-relation",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := body["need_relation"]; got != "<sent only when obj_type is docx>" {
|
||||
t.Fatalf("api[1].body.need_relation = %#v, want conditional placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsOmittedItemsNormalized(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||
}
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsNeedRelationDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []interface{}{}},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["need_relation"]; got != true {
|
||||
t.Fatalf("request need_relation = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsNeedRelationIgnoredForNonDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/sheetResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []interface{}{}},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/sheets/sheetResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if _, ok := body["need_relation"]; ok {
|
||||
t.Fatalf("need_relation must be omitted for non-docx targets: %v", body)
|
||||
}
|
||||
}
|
||||
246
shortcuts/drive/drive_comment_common.go
Normal file
246
shortcuts/drive/drive_comment_common.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// driveCommentOp describes one comment-family shortcut for the shared
|
||||
// --url/--token/--type input resolution. Label appears in error messages;
|
||||
// Types lists the wire file_type values the underlying endpoint accepts.
|
||||
// Wiki URLs/tokens are always accepted as input and unwrapped to the
|
||||
// underlying document, which must then land in Types.
|
||||
type driveCommentOp struct {
|
||||
Label string
|
||||
Types []string
|
||||
}
|
||||
|
||||
func (op driveCommentOp) supports(fileType string) bool {
|
||||
return slices.Contains(op.Types, fileType)
|
||||
}
|
||||
|
||||
// inputTypeList renders the values accepted as input (wire types plus wiki).
|
||||
func (op driveCommentOp) inputTypeList() string {
|
||||
return strings.Join(op.flagEnum(), ", ")
|
||||
}
|
||||
|
||||
// targetTypeList renders the wire types the endpoint accepts (wiki excluded).
|
||||
func (op driveCommentOp) targetTypeList() string {
|
||||
return strings.Join(op.Types, ", ")
|
||||
}
|
||||
|
||||
// flagEnum returns the Enum set for the --type flag: the endpoint's wire
|
||||
// types plus wiki (resolved to a wire type before the API call) and the
|
||||
// base product-name alias when bitable is supported (normalized to bitable).
|
||||
func (op driveCommentOp) flagEnum() []string {
|
||||
enum := make([]string, 0, len(op.Types)+2)
|
||||
for _, t := range op.Types {
|
||||
enum = append(enum, t)
|
||||
if t == "bitable" {
|
||||
enum = append(enum, "base")
|
||||
}
|
||||
}
|
||||
return append(enum, "wiki")
|
||||
}
|
||||
|
||||
// driveCommentRef is the parsed --url/--token/--type input before wiki unwrapping.
|
||||
type driveCommentRef struct {
|
||||
Token string
|
||||
Type string
|
||||
SourceFlag string
|
||||
}
|
||||
|
||||
// driveCommentTarget is the underlying document a comment API call targets.
|
||||
type driveCommentTarget struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
WikiToken string // non-empty when the input was a wiki node
|
||||
}
|
||||
|
||||
// driveCommentTargetFlags returns the shared --url/--token/--type flag trio
|
||||
// used by the comment-family shortcuts that resolve a document target.
|
||||
func driveCommentTargetFlags(op driveCommentOp) []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "url", Desc: fmt.Sprintf("recommended: Lark/Feishu document URL (%s); Wiki URLs are unwrapped automatically", op.inputTypeList())},
|
||||
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
|
||||
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: op.flagEnum()},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDriveCommentInput parses --url/--token/--type into a driveCommentRef,
|
||||
// mirroring +list-comments input handling: --url and --token are mutually
|
||||
// exclusive, URLs are parsed for type+token, bare tokens require --type, and
|
||||
// wiki is always accepted for later unwrapping.
|
||||
func resolveDriveCommentInput(op driveCommentOp, urlInput, tokenInput, explicitType string) (driveCommentRef, error) {
|
||||
urlInput = strings.TrimSpace(urlInput)
|
||||
tokenInput = strings.TrimSpace(tokenInput)
|
||||
if urlInput != "" && tokenInput != "" {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
|
||||
}
|
||||
if urlInput == "" && tokenInput == "" {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
|
||||
}
|
||||
|
||||
raw := urlInput
|
||||
sourceFlag := "--url"
|
||||
if raw == "" {
|
||||
raw = tokenInput
|
||||
sourceFlag = "--token"
|
||||
}
|
||||
inputType := normalizeDriveCommentType(strings.ToLower(strings.TrimSpace(explicitType)))
|
||||
|
||||
if ref, ok := common.ParseResourceURL(raw); ok {
|
||||
refType := normalizeDriveCommentType(ref.Type)
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if refType != "wiki" && !op.supports(refType) {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; %s supports %s",
|
||||
sourceFlag,
|
||||
refType,
|
||||
op.Label,
|
||||
op.inputTypeList(),
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveCommentRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if token, ok := parseDriveListCommentsAppsURL(raw); ok {
|
||||
const refType = "apps"
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if !op.supports(refType) {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; %s supports %s",
|
||||
sourceFlag,
|
||||
refType,
|
||||
op.Label,
|
||||
op.inputTypeList(),
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveCommentRef{Token: token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
|
||||
}
|
||||
if inputType == "" {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: %s)", sourceFlag, op.inputTypeList()).WithParam("--type")
|
||||
}
|
||||
if inputType != "wiki" && !op.supports(inputType) {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: %s", inputType, op.inputTypeList()).WithParam("--type")
|
||||
}
|
||||
return driveCommentRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
// normalizeDriveCommentType maps compatibility aliases to wire values
|
||||
// (base → bitable) so type checks and error messages use one vocabulary.
|
||||
func normalizeDriveCommentType(docType string) string {
|
||||
switch strings.TrimSpace(docType) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.TrimSpace(docType)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDriveCommentTarget unwraps wiki refs to the underlying document via
|
||||
// wiki get_node and validates the resolved type against op.Types.
|
||||
func resolveDriveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, op driveCommentOp, ref driveCommentRef) (driveCommentTarget, error) {
|
||||
if ref.Type != "wiki" {
|
||||
return driveCommentTarget{FileToken: ref.Token, FileType: ref.Type}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": ref.Token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveCommentTarget{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveCommentType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return driveCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "wiki" || !op.supports(objType) {
|
||||
return driveCommentTarget{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but %s only supports %s",
|
||||
objType,
|
||||
op.Label,
|
||||
op.targetTypeList(),
|
||||
).WithParam(ref.SourceFlag)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return driveCommentTarget{FileToken: objToken, FileType: objType, WikiToken: ref.Token}, nil
|
||||
}
|
||||
|
||||
// validateDriveCommentPathID validates a comment/reply identifier destined
|
||||
// for a URL path segment.
|
||||
func validateDriveCommentPathID(value, flagName string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if err := validate.ResourceName(strings.TrimSpace(value), flagName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveCommentItems extracts data.items for output, normalizing a missing or
|
||||
// null field to an empty slice: emitting the server's shape verbatim would
|
||||
// surface "items": null, which breaks jq consumers iterating .data.items[].
|
||||
func driveCommentItems(data map[string]interface{}) []interface{} {
|
||||
if items := common.GetSlice(data, "items"); items != nil {
|
||||
return items
|
||||
}
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// driveCommentTargetOutput assembles the output fields shared by the
|
||||
// comment-family shortcuts: the resolved target plus the wiki origin, if any.
|
||||
func driveCommentTargetOutput(target driveCommentTarget, extra map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": target.FileType,
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
268
shortcuts/drive/drive_comment_common_test.go
Normal file
268
shortcuts/drive/drive_comment_common_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func assertDriveCommentValidationError(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
// assertDriveCommentAPIError asserts the error kept the typed API contract:
|
||||
// CallAPITyped errors must reach the caller unchanged, message-only checks
|
||||
// would still pass if a refactor wrapped them into untyped errors.
|
||||
func assertDriveCommentAPIError(t *testing.T, err error, wantCode int) {
|
||||
t.Helper()
|
||||
|
||||
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 %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
if problem.Subtype == "" {
|
||||
t.Fatalf("subtype is empty, want populated")
|
||||
}
|
||||
if problem.Code != wantCode {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, wantCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDriveCommentInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "sheet", "file", "slides"}}
|
||||
docOnlyOp := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
op driveCommentOp
|
||||
urlInput string
|
||||
rawInput string
|
||||
docType string
|
||||
wantToken string
|
||||
wantType string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "url docx",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
|
||||
wantToken: "docxResource",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "url wiki always accepted",
|
||||
op: docOnlyOp,
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiResource",
|
||||
wantToken: "wikiResource",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "token flag also accepts url",
|
||||
op: op,
|
||||
rawInput: "https://example.larksuite.com/sheets/sheetResource",
|
||||
wantToken: "sheetResource",
|
||||
wantType: "sheet",
|
||||
},
|
||||
{
|
||||
name: "bare token with type",
|
||||
op: op,
|
||||
rawInput: "docxResource",
|
||||
docType: "docx",
|
||||
wantToken: "docxResource",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
op: docOnlyOp,
|
||||
rawInput: "wikiResource",
|
||||
docType: "wiki",
|
||||
wantToken: "wikiResource",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource",
|
||||
rawInput: "docxResource",
|
||||
wantErr: "mutually exclusive",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "missing input",
|
||||
op: op,
|
||||
wantErr: "specify --url or --token",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "bare token needs type",
|
||||
op: op,
|
||||
rawInput: "docxResource",
|
||||
wantErr: "--type is required",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "type conflicts with url",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiResource",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
|
||||
wantErr: "unsupported --url resource type",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type for doc-only op",
|
||||
op: docOnlyOp,
|
||||
urlInput: "https://example.larksuite.com/sheets/sheetResource",
|
||||
wantErr: "comment reply supports doc, docx, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "apps page url",
|
||||
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
|
||||
urlInput: "https://example.feishu.cn/page/appsPageResource/",
|
||||
wantToken: "appsPageResource",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "apps page url rejected by op without apps",
|
||||
op: docOnlyOp,
|
||||
urlInput: "https://example.feishu.cn/page/appsPageResource",
|
||||
wantErr: `unsupported --url resource type "apps"`,
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "apps page url conflicts with explicit type",
|
||||
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
|
||||
urlInput: "https://example.feishu.cn/page/appsPageResource",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "base alias normalized in error",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/base/baseResource",
|
||||
wantErr: `unsupported --url resource type "bitable"`,
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "unrecognized url",
|
||||
op: op,
|
||||
urlInput: "https://example.com/unknown/path",
|
||||
wantErr: "unsupported --url URL",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "bare token with path fragments",
|
||||
op: op,
|
||||
rawInput: "abc/def",
|
||||
docType: "docx",
|
||||
wantErr: "invalid bare token",
|
||||
wantParam: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid explicit type",
|
||||
op: docOnlyOp,
|
||||
rawInput: "sheetResource",
|
||||
docType: "sheet",
|
||||
wantErr: "invalid --type",
|
||||
wantParam: "--type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveDriveCommentInput(tt.op, tt.urlInput, tt.rawInput, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Token != tt.wantToken || got.Type != tt.wantType {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveCommentPathID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateDriveCommentPathID("7457000000000000001", "--comment-id"); err != nil {
|
||||
t.Fatalf("unexpected error for valid ID: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty", value: " ", wantErr: "must not be empty"},
|
||||
{name: "path traversal", value: "../admin", wantErr: "path traversal"},
|
||||
{name: "url metacharacters", value: "abc?x=1", wantErr: "invalid characters"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCommentPathID(tt.value, "--comment-id")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--comment-id")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCommentOpTypeHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
|
||||
if got := op.inputTypeList(); got != "doc, docx, wiki" {
|
||||
t.Fatalf("inputTypeList() = %q, want %q", got, "doc, docx, wiki")
|
||||
}
|
||||
if got := op.targetTypeList(); got != "doc, docx" {
|
||||
t.Fatalf("targetTypeList() = %q, want %q", got, "doc, docx")
|
||||
}
|
||||
if got := op.flagEnum(); len(got) != 3 || got[2] != "wiki" {
|
||||
t.Fatalf("flagEnum() = %v, want types plus trailing wiki", got)
|
||||
}
|
||||
if !op.supports("docx") || op.supports("sheet") || op.supports("wiki") {
|
||||
t.Fatalf("supports() misclassified: docx=%v sheet=%v wiki=%v", op.supports("docx"), op.supports("sheet"), op.supports("wiki"))
|
||||
}
|
||||
}
|
||||
134
shortcuts/drive/drive_delete_reply.go
Normal file
134
shortcuts/drive/drive_delete_reply.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveDeleteReplyOp = driveCommentOp{
|
||||
Label: "reply delete",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveDeleteReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
ReplyID string
|
||||
}
|
||||
|
||||
// DriveDeleteReply deletes a reply of a comment through the Drive comment
|
||||
// reply delete API, while accepting Wiki URLs/tokens and resolving them to
|
||||
// the underlying object.
|
||||
var DriveDeleteReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+delete-reply",
|
||||
Description: "Delete a reply of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveDeleteReplyOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID the reply belongs to (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "reply-id", Desc: "reply ID to delete (from drive +list-comments items[].reply_list.replies[].reply_id)", Required: true},
|
||||
),
|
||||
Tips: []string{
|
||||
"Reply IDs come from `drive +list-comments` (items[].reply_list.replies[].reply_id).",
|
||||
"Deletion is permanent; there is no undo or trash for comment replies.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveDeleteReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveDeleteReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveDeleteReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveDeleteReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveDeleteReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
validate.EncodePathSegment(spec.ReplyID),
|
||||
)
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
nil,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"reply_id": spec.ReplyID,
|
||||
"deleted": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveDeleteReplySpec(runtime *common.RuntimeContext) (driveDeleteReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveDeleteReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveDeleteReplySpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveDeleteReplySpec{}, err
|
||||
}
|
||||
replyID := strings.TrimSpace(runtime.Str("reply-id"))
|
||||
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
|
||||
return driveDeleteReplySpec{}, err
|
||||
}
|
||||
return driveDeleteReplySpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
ReplyID: replyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDriveDeleteReplyDryRun(spec driveDeleteReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> delete reply").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
DELETE("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
|
||||
Desc("[2] Delete reply on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: delete reply").
|
||||
DELETE("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
279
shortcuts/drive/drive_delete_reply_test.go
Normal file
279
shortcuts/drive/drive_delete_reply_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveDeleteReplyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
|
||||
t.Fatalf("reply_id = %q, want reply_2", got)
|
||||
}
|
||||
if got := data["deleted"]; got != true {
|
||||
t.Fatalf("deleted = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyExecuteViaWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "file",
|
||||
"obj_token": "fileFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fileFromWiki/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "file" {
|
||||
t.Errorf("file_type = %q, want file", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
|
||||
t.Fatalf("file_type = %q, want file", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe reply id",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "../reply",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "empty reply id",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", " ",
|
||||
},
|
||||
wantErr: "--reply-id must not be empty",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
"--reply-id", "reply_2",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
},
|
||||
wantErr: "reply delete supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "reply not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "reply not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "DELETE" {
|
||||
t.Fatalf("api[0].method = %q, want DELETE", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[0].url = %q, want resolved path segments", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "DELETE" {
|
||||
t.Fatalf("api[1].method = %q, want DELETE", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -16,47 +19,180 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const driveMetadataReadScope = "drive:drive.metadata:readonly"
|
||||
|
||||
type driveDownloadOutputPathValidator func(string) error
|
||||
|
||||
func driveDownloadNormalizeFileName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = path.Base(name)
|
||||
if name == "" || name == "." || name == ".." || strings.Trim(name, "/") == "" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func driveDownloadFallbackFileName(title, fileToken string) string {
|
||||
if name := driveDownloadNormalizeFileName(title); name != "" {
|
||||
return name
|
||||
}
|
||||
return fileToken
|
||||
}
|
||||
|
||||
func driveDownloadCandidateOutputPath(header http.Header, candidate string) (string, bool) {
|
||||
fileName := driveDownloadNormalizeFileName(candidate)
|
||||
if fileName == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fileName = sanitizeExportFileName(fileName, "")
|
||||
if fileName == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fileName, _ = common.AutoAppendDownloadExtension(fileName, header, "")
|
||||
if strings.TrimSpace(fileName) == "" || fileName == "." || fileName == ".." || strings.Trim(fileName, "/") == "" {
|
||||
return "", false
|
||||
}
|
||||
return fileName, true
|
||||
}
|
||||
|
||||
func driveDownloadDefaultOutputPath(header http.Header, title, fileToken string, validatePath driveDownloadOutputPathValidator) (string, error) {
|
||||
candidates := []string{
|
||||
larkcore.FileNameByHeader(header),
|
||||
title,
|
||||
fileToken,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
fileName, ok := driveDownloadCandidateOutputPath(header, candidate)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if validatePath != nil {
|
||||
if err := validatePath(fileName); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fileName, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
func driveDownloadShouldFailOnMetadataTitleError(ctx context.Context, err error) bool {
|
||||
if ctx != nil {
|
||||
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
if problem.Category == errs.CategoryAuthorization {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var DriveDownload = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+download",
|
||||
Description: "Download a file from Drive to local",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
// Metadata is only required when --output is omitted and the CLI needs the
|
||||
// remote title as the pre-download fallback filename.
|
||||
ConditionalScopes: []string{driveMetadataReadScope},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file token", Required: true},
|
||||
{Name: "output", Desc: "local save path"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
outputPath := runtime.Str("output")
|
||||
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if outputPath == "" {
|
||||
if err := runtime.EnsureScopes([]string{driveMetadataReadScope}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
outputPath := runtime.Str("output")
|
||||
plan := common.NewDryRunAPI()
|
||||
downloadDesc := "[1] Download file bytes to the explicit output path"
|
||||
if outputPath == "" {
|
||||
outputPath = fileToken
|
||||
outputPath = "<Content-Disposition filename | metadata title | token>"
|
||||
downloadDesc = "[2] Download file bytes; Content-Disposition filename wins over metadata title when present"
|
||||
plan.
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[1] Resolve metadata title before downloading; fails before the download request if metadata scope is missing").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
return plan.
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Set("file_token", fileToken).Set("output", outputPath)
|
||||
Desc(downloadDesc).
|
||||
Set("file_token", fileToken).
|
||||
Set("output", outputPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
outputPath := runtime.Str("output")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
outputPath = fileToken
|
||||
}
|
||||
|
||||
// Early path validation + overwrite check
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
if outputPath != "" {
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
}
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
|
||||
var metadataTitle string
|
||||
if outputPath == "" {
|
||||
title, err := common.FetchDriveMetaTitle(runtime, fileToken, "file")
|
||||
if err != nil {
|
||||
if driveDownloadShouldFailOnMetadataTitleError(ctx, err) {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: metadata title lookup failed; continuing with Content-Disposition or token filename: %v\n", err)
|
||||
} else {
|
||||
metadataTitle = title
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
|
||||
@@ -70,6 +206,20 @@ var DriveDownload = common.Shortcut{
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if outputPath == "" {
|
||||
var resolveErr error
|
||||
outputPath, resolveErr = driveDownloadDefaultOutputPath(resp.Header, metadataTitle, fileToken, func(path string) error {
|
||||
_, err := runtime.ResolveSavePath(path)
|
||||
return err
|
||||
})
|
||||
if resolveErr != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot derive a safe default output path: %s", resolveErr).WithCause(resolveErr)
|
||||
}
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
|
||||
@@ -639,12 +639,29 @@ func sanitizeExportFileName(name, fallback string) string {
|
||||
)
|
||||
name = replacer.Replace(name)
|
||||
name = strings.Trim(name, ". ")
|
||||
if name == "" {
|
||||
if name == "" || isWindowsReservedDeviceFileName(name) {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isWindowsReservedDeviceFileName(name string) bool {
|
||||
base := strings.TrimRight(name, ". ")
|
||||
if dot := strings.IndexByte(base, '.'); dot >= 0 {
|
||||
base = base[:dot]
|
||||
}
|
||||
switch strings.ToUpper(base) {
|
||||
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 {
|
||||
prefix := strings.ToUpper(base[:3])
|
||||
suffix := base[3]
|
||||
return (prefix == "COM" || prefix == "LPT") && suffix >= '1' && suffix <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureExportFileExtension appends the expected local suffix when the chosen
|
||||
// file name does not already end with the export format's extension.
|
||||
func ensureExportFileExtension(name, fileExtension string) string {
|
||||
|
||||
@@ -58,6 +58,20 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" {
|
||||
t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf")
|
||||
}
|
||||
for _, name := range []string{"CON.txt", "con.backup.txt", "nul", "COM1.pdf", "lpt9.csv"} {
|
||||
t.Run("reserved-"+name, func(t *testing.T) {
|
||||
if got := sanitizeExportFileName(name, "fallback.bin"); got != "fallback.bin" {
|
||||
t.Fatalf("sanitizeExportFileName(%q) = %q, want fallback.bin", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"CONTEXT.txt", "COM10.pdf", "LPT0.csv"} {
|
||||
t.Run("allowed-"+name, func(t *testing.T) {
|
||||
if got := sanitizeExportFileName(name, "fallback.bin"); got != name {
|
||||
t.Fatalf("sanitizeExportFileName(%q) = %q, want original name", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md")
|
||||
}
|
||||
|
||||
@@ -12,19 +12,31 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn driveRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
var driveTaskCheckPollMu sync.Mutex
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
@@ -34,9 +46,15 @@ func driveTestConfig() *core.CliConfig {
|
||||
}
|
||||
|
||||
func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
return mountAndRunDriveWithContext(t, context.Background(), s, args, f, stdout)
|
||||
}
|
||||
|
||||
func mountAndRunDriveWithContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "drive"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetContext(ctx)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
@@ -1562,6 +1580,613 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="////"`},
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "////", "file_token", nil); got != "file_token" {
|
||||
t.Fatalf("default output path = %q, want file_token", got)
|
||||
}
|
||||
if got := driveDownloadFallbackFileName(`\\`, "file_token"); got != "file_token" {
|
||||
t.Fatalf("fallback filename = %q, want file_token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathSanitizesWindowsReservedCharacters(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="Q1: forecast?.txt"`},
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", nil); got != "Q1_ forecast_.txt" {
|
||||
t.Fatalf("default output path = %q, want Q1_ forecast_.txt", got)
|
||||
}
|
||||
|
||||
header = http.Header{
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "Q1: forecast?", "file_token", nil); got != "Q1_ forecast_.txt" {
|
||||
t.Fatalf("metadata fallback output path = %q, want Q1_ forecast_.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathRejectsWindowsReservedDeviceNames(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="CON.txt"`},
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", nil); got != "Metadata Title.txt" {
|
||||
t.Fatalf("default output path = %q, want Metadata Title.txt", got)
|
||||
}
|
||||
|
||||
header = http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "COM1.pdf", "file_token", nil); got != "file_token" {
|
||||
t.Fatalf("metadata fallback output path = %q, want file_token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathFallsBackWhenHeaderCandidateFailsPathValidation(t *testing.T) {
|
||||
validatePath := func(path string) error {
|
||||
_, err := validate.SafeOutputPath(path)
|
||||
return err
|
||||
}
|
||||
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{"attachment; filename=\"evil\u202etxt\""},
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", validatePath)
|
||||
if got != "Metadata Title.txt" {
|
||||
t.Fatalf("default output path = %q, want Metadata Title.txt", got)
|
||||
}
|
||||
|
||||
header = http.Header{
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
got = mustDriveDownloadDefaultOutputPath(t, header, "evil\u202etxt", "file_token", validatePath)
|
||||
if got != "file_token.txt" {
|
||||
t.Fatalf("metadata fallback output path = %q, want file_token.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDriveDownloadDefaultOutputPath(t *testing.T, header http.Header, title, fileToken string, validatePath driveDownloadOutputPathValidator) string {
|
||||
t.Helper()
|
||||
got, err := driveDownloadDefaultOutputPath(header, title, fileToken, validatePath)
|
||||
if err != nil {
|
||||
t.Fatalf("driveDownloadDefaultOutputPath() error = %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestDriveDownloadDryRunPlansMetadataWhenOutputOmitted(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_dryrun",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
apis, _ := data["api"].([]interface{})
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("api count = %d, want 2\nstdout=%s", len(apis), stdout.String())
|
||||
}
|
||||
first, _ := apis[0].(map[string]interface{})
|
||||
if first["method"] != "POST" || first["url"] != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("first api = %#v, want metadata batch_query", first)
|
||||
}
|
||||
second, _ := apis[1].(map[string]interface{})
|
||||
if second["method"] != "GET" || second["url"] != "/open-apis/drive/v1/files/file_dryrun/download" {
|
||||
t.Fatalf("second api = %#v, want file download", second)
|
||||
}
|
||||
if second["desc"] != "[2] Download file bytes; Content-Disposition filename wins over metadata title when present" {
|
||||
t.Fatalf("second desc = %#v, want metadata-aware step 2", second["desc"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDryRunExplicitOutputSkipsMetadata(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_dryrun",
|
||||
"--output", "report.bin",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
apis, _ := data["api"].([]interface{})
|
||||
if len(apis) != 1 {
|
||||
t.Fatalf("api count = %d, want 1\nstdout=%s", len(apis), stdout.String())
|
||||
}
|
||||
first, _ := apis[0].(map[string]interface{})
|
||||
if first["method"] != "GET" || first["url"] != "/open-apis/drive/v1/files/file_dryrun/download" {
|
||||
t.Fatalf("api = %#v, want file download", first)
|
||||
}
|
||||
if first["desc"] != "[1] Download file bytes to the explicit output path" {
|
||||
t.Fatalf("api desc = %#v, want explicit-output step 1", first["desc"])
|
||||
}
|
||||
if data["output"] != "report.bin" {
|
||||
t.Fatalf("output = %#v, want report.bin", data["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadOmittedOutputRequiresMetadataScope(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:file:download"}, nil)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_no_scope",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing metadata scope error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("problem = category %q subtype %q, want authorization/missing_scope", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadRejectsInvalidFileToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "../bad",
|
||||
"--output", "report.bin",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid file-token error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file-token" {
|
||||
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--file-token", problem.Category, problem.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadRejectsUnsafeExplicitOutput(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_safe",
|
||||
"--output", "../report.bin",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--output", problem.Category, problem.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadExplicitOutputSkipsMetadataScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:file:download"}, nil)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_no_meta_scope/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("bytes"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_no_meta_scope",
|
||||
"--output", "explicit.bin",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(tmpDir, "explicit.bin")); err != nil || string(data) != "bytes" {
|
||||
t.Fatalf("explicit output content = %q, err=%v; want bytes", string(data), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadRejectsExistingDefaultOutputWithoutOverwrite(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_existing_title", "doc_type": "file", "title": "Existing Report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_existing_title/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("new"),
|
||||
Headers: http.Header{"Content-Type": []string{"text/plain"}},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "Existing Report.txt"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_existing_title",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite protection error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--output", problem.Category, problem.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadUsesContentDispositionWhenOutputOmitted(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
metaStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_named", "doc_type": "file", "title": "Metadata Report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(metaStub)
|
||||
metadataSeenBeforeDownload := false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_named/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("downloaded"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Disposition": []string{`attachment; filename="server-report.md"`},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
metadataSeenBeforeDownload = len(metaStub.CapturedBody) > 0
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_named",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !metadataSeenBeforeDownload {
|
||||
t.Fatal("metadata title lookup must happen before download")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "server-report.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "downloaded" {
|
||||
t.Fatalf("downloaded content = %q, want downloaded", string(data))
|
||||
}
|
||||
out := decodeDriveEnvelope(t, stdout)
|
||||
if got := filepath.Base(common.GetString(out, "saved_path")); got != "server-report.md" {
|
||||
t.Fatalf("saved_path base=%q, want server-report.md\nstdout=%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadFallsBackToMetadataTitleWhenOutputOmitted(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_title", "doc_type": "file", "title": "Quarterly Report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_title/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("plain text"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_title",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Quarterly Report.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "plain text" {
|
||||
t.Fatalf("downloaded content = %q, want plain text", string(data))
|
||||
}
|
||||
out := decodeDriveEnvelope(t, stdout)
|
||||
if got := filepath.Base(common.GetString(out, "saved_path")); got != "Quarterly Report.txt" {
|
||||
t.Fatalf("saved_path base=%q, want Quarterly Report.txt\nstdout=%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadFallsBackToTokenWhenOutputOmittedAndMetadataEmpty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_empty/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("bytes"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_empty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "file_empty"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "bytes" {
|
||||
t.Fatalf("downloaded content = %q, want bytes", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadMetadataNonPermissionErrorContinuesWithTokenFallback(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "rate limit",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_rate_limited/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("bytes"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_rate_limited",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: metadata title lookup failed") {
|
||||
t.Fatalf("stderr missing metadata warning: %s", stderr.String())
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "file_rate_limited"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "bytes" {
|
||||
t.Fatalf("downloaded content = %q, want bytes", string(data))
|
||||
}
|
||||
out := decodeDriveEnvelope(t, stdout)
|
||||
if got := filepath.Base(common.GetString(out, "saved_path")); got != "file_rate_limited" {
|
||||
t.Fatalf("saved_path base=%q, want file_rate_limited\nstdout=%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadTypedMetadataTimeoutFallsBack(t *testing.T) {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "metadata lookup timed out")
|
||||
if driveDownloadShouldFailOnMetadataTitleError(context.Background(), err) {
|
||||
t.Fatal("typed metadata timeout should use warning fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadMetadataContextErrorStopsBeforeDownload(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wantErr error
|
||||
makeCtx func() (context.Context, context.CancelFunc)
|
||||
cancelIn func(context.CancelFunc, *http.Request)
|
||||
}{
|
||||
{
|
||||
name: "canceled",
|
||||
wantErr: context.Canceled,
|
||||
makeCtx: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(context.Background())
|
||||
},
|
||||
cancelIn: func(cancel context.CancelFunc, req *http.Request) {
|
||||
cancel()
|
||||
<-req.Context().Done()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
wantErr: context.DeadlineExceeded,
|
||||
makeCtx: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
},
|
||||
cancelIn: func(_ context.CancelFunc, req *http.Request) {
|
||||
<-req.Context().Done()
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runCtx, cancel := tc.makeCtx()
|
||||
defer cancel()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
metadataRequests := 0
|
||||
downloadRequests := 0
|
||||
f.LarkClient = func() (*lark.Client, error) {
|
||||
return lark.NewClient(
|
||||
cfg.AppID,
|
||||
credential.RuntimeAppSecret(cfg.AppSecret),
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(cfg.Brand)),
|
||||
lark.WithHttpClient(&http.Client{Transport: driveRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/metas/batch_query") {
|
||||
metadataRequests++
|
||||
tc.cancelIn(cancel, req)
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
if strings.Contains(req.URL.Path, "/download") {
|
||||
downloadRequests++
|
||||
}
|
||||
return nil, errors.New("unexpected request after metadata context error")
|
||||
})}),
|
||||
), nil
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDriveWithContext(t, runCtx, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_context_error",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
if metadataRequests != 1 {
|
||||
t.Fatalf("metadata requests = %d, want 1", metadataRequests)
|
||||
}
|
||||
if downloadRequests != 0 {
|
||||
t.Fatalf("download requests = %d, want 0", downloadRequests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadMetadataErrorBeforeDownloadWhenOutputOmitted(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_no_meta",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected metadata lookup error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeMissingScope || problem.Code != 99991679 {
|
||||
t.Fatalf("problem = category %q subtype %q code %d, want authorization/missing_scope/99991679", problem.Category, problem.Subtype, problem.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type capturedDriveMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
|
||||
@@ -348,7 +348,7 @@ func driveListCommentsScopeParam(scope string) (*bool, bool) {
|
||||
}
|
||||
|
||||
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
|
||||
items := common.GetSlice(data, "items")
|
||||
items := driveCommentItems(data)
|
||||
return map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": target.FileType,
|
||||
|
||||
@@ -525,3 +525,38 @@ func TestDriveListCommentsExecuteAppsPageURL(t *testing.T) {
|
||||
t.Fatalf("count = %#v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsOmittedItemsNormalized(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": ""},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||
}
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
156
shortcuts/drive/drive_list_replies.go
Normal file
156
shortcuts/drive/drive_list_replies.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveListRepliesOp = driveCommentOp{
|
||||
Label: "replies list",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveListRepliesSpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
PageSize int
|
||||
PageToken string
|
||||
NeedReaction bool
|
||||
}
|
||||
|
||||
// DriveListReplies lists the replies of one comment through the Drive comment
|
||||
// reply list API, while accepting Wiki URLs/tokens and resolving them to the
|
||||
// underlying object.
|
||||
var DriveListReplies = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+list-replies",
|
||||
Description: "List replies of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveListRepliesOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID whose replies to list (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
|
||||
common.Flag{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on replies"},
|
||||
),
|
||||
Tips: []string{
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
"The root reply (the comment body itself) is the earliest-created reply: it is items[0] of the FIRST page only (no --page-token); items[0] of later pages is a regular reply.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveListRepliesSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveListRepliesSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveListRepliesDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveListRepliesSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveListRepliesOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing replies of comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
)
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
path,
|
||||
buildDriveListRepliesParams(spec, target.FileType),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := driveCommentItems(data)
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"items": items,
|
||||
"has_more": common.GetBool(data, "has_more"),
|
||||
"page_token": common.GetString(data, "page_token"),
|
||||
"count": len(items),
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveListRepliesSpec(runtime *common.RuntimeContext) (driveListRepliesSpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveListRepliesOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveListRepliesSpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveListRepliesSpec{}, err
|
||||
}
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
return driveListRepliesSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
return driveListRepliesSpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
PageSize: pageSize,
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
NeedReaction: runtime.Bool("need-reaction"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDriveListRepliesParams(spec driveListRepliesSpec, fileType string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
if spec.NeedReaction {
|
||||
params["need_reaction"] = true
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func buildDriveListRepliesDryRun(spec driveListRepliesSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> list comment replies").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
|
||||
Desc("[2] List replies of comment on resolved document").
|
||||
Params(buildDriveListRepliesParams(spec, "<obj_type from step 1>")).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: list comment replies").
|
||||
GET("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
|
||||
Params(buildDriveListRepliesParams(spec, spec.Ref.Type)).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
425
shortcuts/drive/drive_list_replies_test.go
Normal file
425
shortcuts/drive/drive_list_replies_test.go
Normal file
@@ -0,0 +1,425 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveListRepliesExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := query.Get("page_size"); got != "50" {
|
||||
t.Errorf("page_size = %q, want 50 (default)", got)
|
||||
}
|
||||
if query.Has("page_token") {
|
||||
t.Errorf("page_token should be omitted when not set, got %q", query.Get("page_token"))
|
||||
}
|
||||
if query.Has("need_reaction") {
|
||||
t.Errorf("need_reaction should be omitted when not set, got %q", query.Get("need_reaction"))
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"reply_id": "reply_1",
|
||||
"content": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "text_run",
|
||||
"text_run": map[string]interface{}{"text": "根回复正文"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{"reply_id": "reply_2"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
items := mustSliceValue(t, data["items"], "data.items")
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("len(items) = %d, want 2", len(items))
|
||||
}
|
||||
firstItem := mustMapValue(t, items[0], "data.items[0]")
|
||||
if got := mustStringField(t, firstItem, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
|
||||
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
|
||||
}
|
||||
firstContent := mustMapValue(t, firstItem["content"], "data.items[0].content")
|
||||
firstElements := mustSliceValue(t, firstContent["elements"], "data.items[0].content.elements")
|
||||
firstElement := mustMapValue(t, firstElements[0], "data.items[0].content.elements[0]")
|
||||
firstText := mustMapValue(t, firstElement["text_run"], "data.items[0].content.elements[0].text_run")
|
||||
if got := mustStringField(t, firstText, "text", "data.items[0].content.elements[0].text_run.text"); got != "根回复正文" {
|
||||
t.Fatalf("items[0] text = %q, want 根回复正文", got)
|
||||
}
|
||||
secondItem := mustMapValue(t, items[1], "data.items[1]")
|
||||
if got := mustStringField(t, secondItem, "reply_id", "data.items[1].reply_id"); got != "reply_2" {
|
||||
t.Fatalf("items[1].reply_id = %q, want reply_2", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(2) {
|
||||
t.Fatalf("count = %#v, want 2", got)
|
||||
}
|
||||
if got := data["has_more"]; got != true {
|
||||
t.Fatalf("has_more = %#v, want true", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "page_token", "data.page_token"); got != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesExecuteViaWikiToBitable(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bitableFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/bitableFromWiki/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"reply_id": "reply_1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("file_type = %q, want bitable", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
items := mustSliceValue(t, data["items"], "data.items")
|
||||
item := mustMapValue(t, items[0], "data.items[0]")
|
||||
if got := mustStringField(t, item, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
|
||||
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesPaginationAndReactionParams(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("page_size"); got != "10" {
|
||||
t.Errorf("page_size = %q, want 10", got)
|
||||
}
|
||||
if got := query.Get("page_token"); got != "cursor_1" {
|
||||
t.Errorf("page_token = %q, want cursor_1", got)
|
||||
}
|
||||
if got := query.Get("need_reaction"); got != "true" {
|
||||
t.Errorf("need_reaction = %q, want true", got)
|
||||
}
|
||||
if got := query.Get("user_id_type"); got != "" {
|
||||
t.Errorf("user_id_type = %q, want omitted (flag removed)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--page-size", "10",
|
||||
"--page-token", "cursor_1",
|
||||
"--need-reaction",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "empty comment id",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", " ",
|
||||
},
|
||||
wantErr: "--comment-id must not be empty",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "page size too small",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--page-size", "0",
|
||||
},
|
||||
wantErr: "--page-size must be between 1 and 100",
|
||||
wantParam: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "page size too large",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--page-size", "101",
|
||||
},
|
||||
wantErr: "--page-size must be between 1 and 100",
|
||||
wantParam: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
},
|
||||
wantErr: "replies list supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveListReplies, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069301,
|
||||
"msg": "comment not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "comment not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--need-reaction",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "GET" {
|
||||
t.Fatalf("api[0].method = %q, want GET", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
|
||||
t.Fatalf("api[0].url = %q, want resolved path segments", got)
|
||||
}
|
||||
params := mustMapValue(t, call["params"], "api[0].params")
|
||||
if got := params["need_reaction"]; got != true {
|
||||
t.Fatalf("api[0].params.need_reaction = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "GET" {
|
||||
t.Fatalf("api[1].method = %q, want GET", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
|
||||
t.Fatalf("api[1].url = %q, want resolved comment ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesOmittedItemsNormalized(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": ""},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||
}
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,10 @@ var driveMemberAddURLPathToType = []struct {
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
{"/page/", "apps"},
|
||||
}
|
||||
|
||||
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
|
||||
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps"}
|
||||
|
||||
const driveMemberAddBatchLimit = 10
|
||||
|
||||
@@ -61,7 +62,7 @@ const driveMemberAddBatchLimit = 10
|
||||
var DriveMemberAdd = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-add",
|
||||
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
|
||||
Description: "Add a collaborator/member permission to a Drive resource",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:permission.member:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -320,7 +321,7 @@ func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string
|
||||
|
||||
func isSupportedDriveMemberAddResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
|
||||
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -35,10 +35,12 @@ func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) {
|
||||
{"folder URL", "https://example.feishu.cn/drive/folder/fldTok", "", "fldTok", "folder"},
|
||||
{"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
|
||||
{"mindnotes URL", "https://example.feishu.cn/mindnotes/mndTok", "", "mndTok", "mindnote"},
|
||||
{"apps page URL", "https://example.feishu.cn/page/appMetaTok/?from=share", "", "appMetaTok", "apps"},
|
||||
{"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "", "doxTok", "docx"},
|
||||
{"explicit type overrides URL", "https://example.feishu.cn/docx/doxTok", "wiki", "doxTok", "wiki"},
|
||||
{"bare token with explicit docx type", "N83ZduEnHooFswxnVWGcazlLnFf", "docx", "N83ZduEnHooFswxnVWGcazlLnFf", "docx"},
|
||||
{"bare token with explicit folder type", "fldToken123", "folder", "fldToken123", "folder"},
|
||||
{"bare token with explicit apps type", "appMetaTok", "apps", "appMetaTok", "apps"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
|
||||
326
shortcuts/drive/drive_member_list.go
Normal file
326
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,326 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder", "apps",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
{"/page/", "apps"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive resource",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder/apps)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
439
shortcuts/drive/drive_member_list_test.go
Normal file
439
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "apps page URL",
|
||||
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
|
||||
wantTok: "appMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit apps type",
|
||||
token: "appBareMetaTok",
|
||||
docType: "apps",
|
||||
wantTok: "appBareMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
291
shortcuts/drive/drive_permission_get_setting.go
Normal file
291
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
type drivePermissionGetSettingResourceKind struct {
|
||||
Type string
|
||||
CanonicalPath string
|
||||
PathAliases []string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingResourceKinds = []drivePermissionGetSettingResourceKind{
|
||||
{Type: "doc", CanonicalPath: "/doc/"},
|
||||
{Type: "sheet", CanonicalPath: "/sheets/"},
|
||||
{Type: "file", CanonicalPath: "/file/"},
|
||||
{Type: "wiki", CanonicalPath: "/wiki/"},
|
||||
{Type: "bitable", CanonicalPath: "/base/", PathAliases: []string{"/bitable/"}},
|
||||
{Type: "docx", CanonicalPath: "/docx/"},
|
||||
{Type: "mindnote", CanonicalPath: "/mindnote/", PathAliases: []string{"/mindnotes/"}},
|
||||
{Type: "minutes", CanonicalPath: "/minutes/"},
|
||||
{Type: "slides", CanonicalPath: "/slides/"},
|
||||
{Type: "folder", CanonicalPath: "/drive/folder/"},
|
||||
{Type: "apps", CanonicalPath: "/page/"},
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = func() []string {
|
||||
types := make([]string, 0, len(drivePermissionGetSettingResourceKinds))
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
types = append(types, resourceKind.Type)
|
||||
}
|
||||
return types
|
||||
}()
|
||||
|
||||
func findDrivePermissionGetSettingResourceKind(docType string) (drivePermissionGetSettingResourceKind, bool) {
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
if docType == resourceKind.Type {
|
||||
return resourceKind, true
|
||||
}
|
||||
}
|
||||
return drivePermissionGetSettingResourceKind{}, false
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourcePath(path, prefix, docType string) (common.ResourceRef, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
escapedToken := strings.TrimSuffix(path[len(prefix):], "/")
|
||||
if escapedToken == "" || strings.Contains(escapedToken, "/") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
token, err := url.PathUnescape(escapedToken)
|
||||
if err != nil || token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: docType, Token: token}, true
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceKindPath(path string, resourceKind drivePermissionGetSettingResourceKind) (common.ResourceRef, bool) {
|
||||
if ref, ok := parseDrivePermissionGetSettingResourcePath(path, resourceKind.CanonicalPath, resourceKind.Type); ok {
|
||||
return ref, true
|
||||
}
|
||||
for _, alias := range resourceKind.PathAliases {
|
||||
if ref, ok := parseDrivePermissionGetSettingResourcePath(path, alias, resourceKind.Type); ok {
|
||||
return ref, true
|
||||
}
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validateDrivePermissionGetSettingToken(ref.Token); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, err
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validateDrivePermissionGetSettingToken(rawToken); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, err
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
if ref, ok := parseDrivePermissionGetSettingResourceKindPath(parsed.EscapedPath(), resourceKind); ok {
|
||||
return ref, true
|
||||
}
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
_, ok := findDrivePermissionGetSettingResourceKind(docType)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
resourceKind, ok := findDrivePermissionGetSettingResourceKind(s.Type)
|
||||
token := strings.TrimSpace(s.Token)
|
||||
if !ok || token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
brand := core.LarkBrand("")
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
brand = runtime.Config.Brand
|
||||
}
|
||||
host := "https://www.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "https://www.larksuite.com"
|
||||
}
|
||||
return host + resourceKind.CanonicalPath + url.PathEscape(token)
|
||||
}
|
||||
|
||||
func validateDrivePermissionGetSettingToken(token string) error {
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if token == "." || strings.Contains(token, "/") {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must be a non-dot single path segment",
|
||||
).WithParam("--token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive resource.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder/apps)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
585
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
585
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,585 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "canonical mindnote URL",
|
||||
token: "https://example.feishu.cn/mindnote/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "apps page URL",
|
||||
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
|
||||
wantTok: "appMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit apps type",
|
||||
token: "appBareMetaTok",
|
||||
docType: "apps",
|
||||
wantTok: "appBareMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingResourceKindsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const token = "resourceRoundTripTok"
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
kind := resourceKind
|
||||
t.Run(kind.Type, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bareRuntime := newDrivePermissionGetSettingRuntime(t, token, kind.Type)
|
||||
bareSpec, err := readDrivePermissionGetSettingSpec(bareRuntime)
|
||||
if err != nil {
|
||||
t.Fatalf("read bare-token spec: %v", err)
|
||||
}
|
||||
resourceURL := bareSpec.url(bareRuntime)
|
||||
if resourceURL == "" {
|
||||
t.Fatalf("resource URL is empty for allowed type %q", kind.Type)
|
||||
}
|
||||
|
||||
urlRuntime := newDrivePermissionGetSettingRuntime(t, resourceURL, "")
|
||||
urlSpec, err := readDrivePermissionGetSettingSpec(urlRuntime)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated URL spec %q: %v", resourceURL, err)
|
||||
}
|
||||
if urlSpec.Token != token || urlSpec.Type != kind.Type {
|
||||
t.Fatalf(
|
||||
"generated URL resolved to token/type %q/%q, want %q/%q",
|
||||
urlSpec.Token,
|
||||
urlSpec.Type,
|
||||
token,
|
||||
kind.Type,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, "appMetaTok", "apps")
|
||||
runtime.Config.Brand = core.BrandLark
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want {
|
||||
t.Fatalf("resource URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "bare token contains path separator",
|
||||
token: "doxTok/other",
|
||||
docType: "docx",
|
||||
wantParam: "--token",
|
||||
wantMessage: "single path segment",
|
||||
},
|
||||
{
|
||||
name: "bare dot token",
|
||||
token: ".",
|
||||
docType: "docx",
|
||||
wantParam: "--token",
|
||||
wantMessage: "non-dot single path segment",
|
||||
},
|
||||
{
|
||||
name: "non-HTTP URL",
|
||||
token: "ftp://example.feishu.cn/docx/doxTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesResourceURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
name: "apps",
|
||||
token: "appMetaTok",
|
||||
docType: "apps",
|
||||
wantURL: "https://www.feishu.cn/page/appMetaTok",
|
||||
},
|
||||
{
|
||||
name: "minutes",
|
||||
token: "obcnMinuteTok",
|
||||
docType: "minutes",
|
||||
wantURL: "https://www.feishu.cn/minutes/obcnMinuteTok",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/" + tt.token + "/public?type=" + tt.docType,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", tt.token,
|
||||
"--type", tt.docType,
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "URL: "+tt.wantURL) {
|
||||
t.Fatalf("pretty output missing resource URL %q:\n%s", tt.wantURL, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
215
shortcuts/drive/drive_react_reply.go
Normal file
215
shortcuts/drive/drive_react_reply.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveReactReplyOp = driveCommentOp{
|
||||
Label: "reply reaction",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
const (
|
||||
driveReactReplyActionAdd = "add"
|
||||
driveReactReplyActionDelete = "delete"
|
||||
)
|
||||
|
||||
// driveReactReplyReactionTypes mirrors the reaction_type enum from the
|
||||
// platform metadata (file.comment.reply.reactions.update_reaction). The
|
||||
// server does NOT validate this field — an arbitrary string is accepted and
|
||||
// persisted as a broken reaction on the reply, so this local check is the
|
||||
// only guard. Values are case-sensitive.
|
||||
var driveReactReplyReactionTypes = map[string]struct{}{
|
||||
"ANGRY": {}, "APPLAUSE": {}, "ATTENTION": {}, "AWESOME": {}, "BEAR": {}, "BEER": {},
|
||||
"BETRAYED": {}, "BIGKISS": {}, "BLACKFACE": {}, "BLUBBER": {}, "BLUSH": {}, "BOMB": {},
|
||||
"CAKE": {}, "CHUCKLE": {}, "CLAP": {}, "CLEAVER": {}, "COMFORT": {}, "CRAZY": {}, "CRY": {},
|
||||
"CUCUMBER": {}, "DETERGENT": {}, "DIZZY": {}, "DONE": {}, "DONNOTGO": {}, "DROOL": {},
|
||||
"DROWSY": {}, "DULL": {}, "DULLSTARE": {}, "EATING": {}, "EMBARRASSED": {}, "ENOUGH": {},
|
||||
"ERROR": {}, "EYESCLOSED": {}, "FACEPALM": {}, "FINGERHEART": {}, "FISTBUMP": {},
|
||||
"FOLLOWME": {}, "FROWN": {}, "GIFT": {}, "GLANCE": {}, "GOODJOB": {}, "HAMMER": {},
|
||||
"HAUGHTY": {}, "HEADSET": {}, "HEART": {}, "HEARTBROKEN": {}, "HIGHFIVE": {}, "HUG": {},
|
||||
"HUSKY": {}, "INNOCENTSMILE": {}, "JIAYI": {}, "JOYFUL": {}, "KISS": {}, "LAUGH": {},
|
||||
"LIPS": {}, "LOL": {}, "LOOKDOWN": {}, "LOVE": {}, "MONEY": {}, "MUSCLE": {},
|
||||
"NOSEPICK": {}, "OBSESSED": {}, "OK": {}, "PARTY": {}, "PETRIFIED": {}, "POOP": {},
|
||||
"PRAISE": {}, "PROUD": {}, "PUKE": {}, "RAINBOWPUKE": {}, "ROSE": {}, "SALUTE": {},
|
||||
"SCOWL": {}, "SHAKE": {}, "SHHH": {}, "SHOCKED": {}, "SHOWOFF": {}, "SHY": {}, "SICK": {},
|
||||
"SILENT": {}, "SKULL": {}, "SLAP": {}, "SLEEP": {}, "SLIGHT": {}, "SMART": {}, "SMILE": {},
|
||||
"SMIRK": {}, "SMOOCH": {}, "SMUG": {}, "SOB": {}, "SPEECHLESS": {}, "SPITBLOOD": {},
|
||||
"STRIVE": {}, "SWEAT": {}, "TEARS": {}, "TEASE": {}, "TERROR": {}, "THANKS": {},
|
||||
"THINKING": {}, "THUMBSUP": {}, "TOASTED": {}, "TONGUE": {}, "TRICK": {}, "UPPERLEFT": {},
|
||||
"WAIL": {}, "WAVE": {}, "WELLDONE": {}, "WHAT": {}, "WHIMPER": {}, "WINK": {}, "WITTY": {},
|
||||
"WOW": {}, "WRONGED": {}, "XBLUSH": {}, "YAWN": {}, "YEAH": {}, "FIREWORKS": {}, "BULL": {},
|
||||
"CALF": {}, "AWESOMEN": {}, "2021": {}, "CANDIEDHAWS": {}, "REDPACKET": {}, "FORTUNE": {},
|
||||
"LUCK": {}, "FIRECRACKER": {}, "Yes": {}, "No": {}, "Get": {}, "LGTM": {}, "Lemon": {},
|
||||
"EatingFood": {}, "Hundred": {}, "MinusOne": {}, "ThumbsDown": {}, "Fire": {}, "OKR": {},
|
||||
"Drumstick": {}, "BubbleTea": {}, "Loudspeaker": {}, "Pin": {}, "Coffee": {}, "Alarm": {},
|
||||
"Trophy": {}, "Music": {}, "Typing": {}, "Pepper": {}, "CheckMark": {}, "CrossMark": {},
|
||||
}
|
||||
|
||||
type driveReactReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
ReplyID string
|
||||
ReactionType string
|
||||
Action string
|
||||
}
|
||||
|
||||
func (s driveReactReplySpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"action": s.Action,
|
||||
"reaction_type": s.ReactionType,
|
||||
"reply_id": s.ReplyID,
|
||||
}
|
||||
}
|
||||
|
||||
// DriveReactReply adds or removes an emoji reaction on a comment reply
|
||||
// through the Drive comment reaction API (POST /drive/v2/files/:file_token/
|
||||
// comments/reaction), while accepting Wiki URLs/tokens and resolving them to
|
||||
// the underlying object.
|
||||
var DriveReactReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+react-reply",
|
||||
Description: "Add or remove an emoji reaction on a comment reply for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveReactReplyOp),
|
||||
common.Flag{Name: "reply-id", Desc: "reply ID to react to (from drive +list-replies); the root reply carries the comment body", Required: true},
|
||||
common.Flag{Name: "emoji", Desc: "reaction_type value, case-sensitive, e.g. THUMBSUP, HEART, DONE, OK", Required: true},
|
||||
common.Flag{Name: "action", Desc: "add attaches the reaction; delete removes the current identity's reaction", Required: true, Enum: []string{driveReactReplyActionAdd, driveReactReplyActionDelete}},
|
||||
),
|
||||
Tips: []string{
|
||||
"Reply IDs come from `drive +list-replies` (items[].reply_id); reacting to the root reply reacts to the comment itself.",
|
||||
"--emoji is case-sensitive and validated locally against the platform reaction_type list (the server accepts and persists arbitrary strings as broken reactions); the full list is in the lark-drive reactions guide.",
|
||||
"Read reactions back via --need-reaction on `drive +list-replies` / `drive +batch-query-comments`; entries with count=0 are leftovers of removed reactions — filter by count>0.",
|
||||
"add and delete are idempotent: re-adding an existing reaction or deleting an absent one succeeds without change. delete only cancels the current identity's reaction.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveReactReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveReactReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveReactReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveReactReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveReactReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Reaction %s (%s) on reply %s in %s...\n", spec.Action, spec.ReactionType, spec.ReplyID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf("/open-apis/drive/v2/files/%s/comments/reaction", validate.EncodePathSegment(target.FileToken))
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"reply_id": spec.ReplyID,
|
||||
"reaction_type": spec.ReactionType,
|
||||
"action": spec.Action,
|
||||
"updated": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveReactReplySpec(runtime *common.RuntimeContext) (driveReactReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveReactReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveReactReplySpec{}, err
|
||||
}
|
||||
replyID := strings.TrimSpace(runtime.Str("reply-id"))
|
||||
if replyID == "" {
|
||||
return driveReactReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reply-id must not be empty").WithParam("--reply-id")
|
||||
}
|
||||
reactionType, err := parseDriveReactReplyEmoji(runtime.Str("emoji"))
|
||||
if err != nil {
|
||||
return driveReactReplySpec{}, err
|
||||
}
|
||||
action, err := parseDriveReactReplyAction(runtime.Str("action"))
|
||||
if err != nil {
|
||||
return driveReactReplySpec{}, err
|
||||
}
|
||||
return driveReactReplySpec{
|
||||
Ref: ref,
|
||||
ReplyID: replyID,
|
||||
ReactionType: reactionType,
|
||||
Action: action,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseDriveReactReplyEmoji validates the reaction_type against the platform
|
||||
// enum. Case matters: the wire values mix all-caps and CamelCase (THUMBSUP vs
|
||||
// ThumbsDown), and the server persists any unknown string as a broken
|
||||
// reaction instead of rejecting it.
|
||||
func parseDriveReactReplyEmoji(raw string) (string, error) {
|
||||
emoji := strings.TrimSpace(raw)
|
||||
if emoji == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji must not be empty").WithParam("--emoji")
|
||||
}
|
||||
if _, ok := driveReactReplyReactionTypes[emoji]; !ok {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unknown --emoji %q; reaction_type values are case-sensitive (e.g. THUMBSUP, HEART, DONE, OK) — see the lark-drive reactions guide for the full list",
|
||||
emoji,
|
||||
).WithParam("--emoji")
|
||||
}
|
||||
return emoji, nil
|
||||
}
|
||||
|
||||
// parseDriveReactReplyAction normalizes and validates the --action value.
|
||||
// The flag's Enum already rejects unknown values from the CLI, so the error
|
||||
// branch only guards direct callers.
|
||||
func parseDriveReactReplyAction(raw string) (string, error) {
|
||||
action := strings.ToLower(strings.TrimSpace(raw))
|
||||
if action != driveReactReplyActionAdd && action != driveReactReplyActionDelete {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --action %q; allowed: %s, %s", raw, driveReactReplyActionAdd, driveReactReplyActionDelete).WithParam("--action")
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func buildDriveReactReplyDryRun(spec driveReactReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> update reply reaction").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
POST("/open-apis/drive/v2/files/<obj_token from step 1>/comments/reaction").
|
||||
Desc("[2] Add or remove the reaction on the resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: update reply reaction").
|
||||
POST("/open-apis/drive/v2/files/:file_token/comments/reaction").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
386
shortcuts/drive/drive_react_reply_test.go
Normal file
386
shortcuts/drive/drive_react_reply_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveReactReplyExecuteDocxAdd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := mustStringField(t, body, "action", "request.action"); got != "add" {
|
||||
t.Fatalf("request action = %q, want add", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "THUMBSUP" {
|
||||
t.Fatalf("request reaction_type = %q, want THUMBSUP", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reply_id", "request.reply_id"); got != "reply_1" {
|
||||
t.Fatalf("request reply_id = %q, want reply_1", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_1" {
|
||||
t.Fatalf("reply_id = %q, want reply_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reaction_type", "data.reaction_type"); got != "THUMBSUP" {
|
||||
t.Fatalf("reaction_type = %q, want THUMBSUP", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "action", "data.action"); got != "add" {
|
||||
t.Fatalf("action = %q, want add", got)
|
||||
}
|
||||
if got := data["updated"]; got != true {
|
||||
t.Fatalf("updated = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyExecuteViaWikiDelete(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "slidesFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v2/files/slidesFromWiki/comments/reaction",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "slides" {
|
||||
t.Errorf("file_type = %q, want slides", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "ThumbsDown",
|
||||
"--action", "delete",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := mustStringField(t, body, "action", "request.action"); got != "delete" {
|
||||
t.Fatalf("request action = %q, want delete", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "ThumbsDown" {
|
||||
t.Fatalf("request reaction_type = %q, want ThumbsDown (case preserved)", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "slides" {
|
||||
t.Fatalf("file_type = %q, want slides", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "empty reply id",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", " ",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: "--reply-id must not be empty",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "unknown emoji",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "FOOBAR",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: `unknown --emoji "FOOBAR"`,
|
||||
wantParam: "--emoji",
|
||||
},
|
||||
{
|
||||
name: "emoji is case sensitive",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "thumbsup",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: `unknown --emoji "thumbsup"`,
|
||||
wantParam: "--emoji",
|
||||
},
|
||||
{
|
||||
name: "invalid action",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "toggle",
|
||||
},
|
||||
wantErr: `invalid value "toggle" for --action`,
|
||||
wantParam: "--action",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: "reply reaction supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveReactReply, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveReactReplyEmoji(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := []string{"THUMBSUP", "ThumbsDown", "Yes", "2021", " HEART "}
|
||||
for _, in := range valid {
|
||||
got, err := parseDriveReactReplyEmoji(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDriveReactReplyEmoji(%q) unexpected error: %v", in, err)
|
||||
}
|
||||
if got != strings.TrimSpace(in) {
|
||||
t.Fatalf("parseDriveReactReplyEmoji(%q) = %q, want %q", in, got, strings.TrimSpace(in))
|
||||
}
|
||||
}
|
||||
|
||||
for _, in := range []string{"", " ", "YES", "heart", "THUMBS_UP"} {
|
||||
if _, err := parseDriveReactReplyEmoji(in); err == nil {
|
||||
t.Fatalf("parseDriveReactReplyEmoji(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveReactReplyAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for in, want := range map[string]string{"add": "add", " DELETE ": "delete", "Add": "add"} {
|
||||
got, err := parseDriveReactReplyAction(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDriveReactReplyAction(%q) unexpected error: %v", in, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("parseDriveReactReplyAction(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := parseDriveReactReplyAction("toggle"); err == nil {
|
||||
t.Fatal("parseDriveReactReplyAction(toggle) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069301,
|
||||
"msg": "reply not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "reply not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
assertDriveCommentAPIError(t, err, 1069301)
|
||||
}
|
||||
|
||||
func TestDriveReactReplyWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "HEART",
|
||||
"--action", "add",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "POST" {
|
||||
t.Fatalf("api[0].method = %q, want POST", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/drive/v2/files/docxResource/comments/reaction") {
|
||||
t.Fatalf("api[0].url = %q, want v2 reaction path", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if got := mustStringField(t, body, "reaction_type", "api[0].body.reaction_type"); got != "HEART" {
|
||||
t.Fatalf("body.reaction_type = %q, want HEART", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "action", "api[0].body.action"); got != "add" {
|
||||
t.Fatalf("body.action = %q, want add", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reply_id", "api[0].body.reply_id"); got != "reply_1" {
|
||||
t.Fatalf("body.reply_id = %q, want reply_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "OK",
|
||||
"--action", "delete",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
|
||||
t.Fatalf("api[1].method = %q, want POST", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/reaction") {
|
||||
t.Fatalf("api[1].url = %q, want reaction path", got)
|
||||
}
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := mustStringField(t, body, "action", "api[1].body.action"); got != "delete" {
|
||||
t.Fatalf("api[1].body.action = %q, want delete", got)
|
||||
}
|
||||
}
|
||||
168
shortcuts/drive/drive_resolve_comment.go
Normal file
168
shortcuts/drive/drive_resolve_comment.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveCommentSolvedSpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
Solved bool
|
||||
}
|
||||
|
||||
func (s driveCommentSolvedSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"is_solved": s.Solved,
|
||||
}
|
||||
}
|
||||
|
||||
// driveCommentSolvedConfig parameterizes the two solved-state shortcuts:
|
||||
// they share one PATCH endpoint whose body is only {is_solved}, so the
|
||||
// commands differ solely in direction and wording.
|
||||
type driveCommentSolvedConfig struct {
|
||||
Command string
|
||||
Description string
|
||||
Label string // driveCommentOp label used in unsupported-type errors
|
||||
Action string // echoed in output and dry-run descriptions
|
||||
Verb string // progress-line verb
|
||||
Solved bool
|
||||
Tip string // direction-specific tip (counterpart pointer)
|
||||
}
|
||||
|
||||
// DriveResolveComment marks a comment solved through the Drive comment patch
|
||||
// API, while accepting Wiki URLs/tokens and resolving them to the underlying
|
||||
// object. Reopening is the separate +restore-comment command.
|
||||
var DriveResolveComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
|
||||
Command: "+resolve-comment",
|
||||
Description: "Resolve (mark solved) a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Label: "comment resolve",
|
||||
Action: "resolve",
|
||||
Verb: "Resolving",
|
||||
Solved: true,
|
||||
Tip: "To reopen a solved comment, use `drive +restore-comment`.",
|
||||
})
|
||||
|
||||
// DriveRestoreComment reopens a solved comment through the same Drive comment
|
||||
// patch API (is_solved=false).
|
||||
var DriveRestoreComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
|
||||
Command: "+restore-comment",
|
||||
Description: "Restore (reopen) a solved comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Label: "comment restore",
|
||||
Action: "restore",
|
||||
Verb: "Restoring",
|
||||
Solved: false,
|
||||
Tip: "To mark a comment solved, use `drive +resolve-comment`.",
|
||||
})
|
||||
|
||||
func newDriveCommentSolvedShortcut(cfg driveCommentSolvedConfig) common.Shortcut {
|
||||
op := driveCommentOp{
|
||||
Label: cfg.Label,
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
readSpec := func(runtime *common.RuntimeContext) (driveCommentSolvedSpec, error) {
|
||||
ref, err := resolveDriveCommentInput(op, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveCommentSolvedSpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveCommentSolvedSpec{}, err
|
||||
}
|
||||
return driveCommentSolvedSpec{Ref: ref, CommentID: commentID, Solved: cfg.Solved}, nil
|
||||
}
|
||||
|
||||
return common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: cfg.Command,
|
||||
Description: cfg.Description,
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(op),
|
||||
common.Flag{Name: "comment-id", Desc: fmt.Sprintf("comment ID to %s (from drive +list-comments)", cfg.Action), Required: true},
|
||||
),
|
||||
Tips: []string{
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
cfg.Tip,
|
||||
"Back-to-back solved-state flips on the same comment can hit server rate limiting (HTTP 429); space out consecutive calls or retry after a short delay.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveCommentSolvedDryRun(cfg, spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, op, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s comment %s in %s...\n", cfg.Verb, spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
)
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"PATCH",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"action": cfg.Action,
|
||||
"is_solved": spec.Solved,
|
||||
"updated": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveCommentSolvedDryRun(cfg driveCommentSolvedConfig, spec driveCommentSolvedSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc(fmt.Sprintf("2-step orchestration: resolve wiki -> %s comment", cfg.Action)).
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
PATCH("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id").
|
||||
Desc(fmt.Sprintf("[2] %s comment (is_solved=%t) on resolved document", cfg.Verb, cfg.Solved)).
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(fmt.Sprintf("1-step request: %s comment (is_solved=%t)", cfg.Action, cfg.Solved)).
|
||||
PATCH("/open-apis/drive/v1/files/:file_token/comments/:comment_id").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
376
shortcuts/drive/drive_resolve_comment_test.go
Normal file
376
shortcuts/drive/drive_resolve_comment_test.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveResolveCommentExecute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["is_solved"]; got != true {
|
||||
t.Fatalf("request is_solved = %#v, want true", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "action", "data.action"); got != "resolve" {
|
||||
t.Fatalf("action = %q, want resolve", got)
|
||||
}
|
||||
if got := data["is_solved"]; got != true {
|
||||
t.Fatalf("is_solved = %#v, want true", got)
|
||||
}
|
||||
if got := data["updated"]; got != true {
|
||||
t.Fatalf("updated = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRestoreCommentExecuteViaWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_9",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveRestoreComment, []string{
|
||||
"+restore-comment",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_9",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["is_solved"]; got != false {
|
||||
t.Fatalf("request is_solved = %#v, want false", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "action", "data.action"); got != "restore" {
|
||||
t.Fatalf("action = %q, want restore", got)
|
||||
}
|
||||
if got := data["is_solved"]; got != false {
|
||||
t.Fatalf("is_solved = %#v, want false", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentExecuteWikiResolvesToBitable(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "baseFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/baseFromWiki/comments/comment_3",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("file_type = %q, want bitable", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCommentSolvedValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
shortcut: "resolve",
|
||||
args: []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "empty comment id",
|
||||
shortcut: "resolve",
|
||||
args: []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", " ",
|
||||
},
|
||||
wantErr: "must not be empty",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "restore rejects unsupported url type",
|
||||
shortcut: "restore",
|
||||
args: []string{
|
||||
"+restore-comment",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
},
|
||||
wantErr: "comment restore supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
shortcut := DriveResolveComment
|
||||
if tt.shortcut == "restore" {
|
||||
shortcut = DriveRestoreComment
|
||||
}
|
||||
err := mountAndRunDrive(t, shortcut, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentInputConflict(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--token", "docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual-exclusion error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069303,
|
||||
"msg": "no comment permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "no comment permission") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentPropagatesWikiResolveError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230005,
|
||||
"msg": "wiki node not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki node not found") {
|
||||
t.Fatalf("expected wiki resolve error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PATCH" {
|
||||
t.Fatalf("api[1].method = %q, want PATCH", got)
|
||||
}
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := body["is_solved"]; got != true {
|
||||
t.Fatalf("api[1].body.is_solved = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRestoreCommentDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveRestoreComment, []string{
|
||||
"+restore-comment",
|
||||
"--url", "https://example.larksuite.com/sheets/sheetResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "PATCH" {
|
||||
t.Fatalf("api[0].method = %q, want PATCH", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/sheetResource/comments/comment_1") {
|
||||
t.Fatalf("api[0].url = %q, want resolved file and comment tokens", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if got := body["is_solved"]; got != false {
|
||||
t.Fatalf("api[0].body.is_solved = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRestoreCommentDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveRestoreComment, []string{
|
||||
"+restore-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := body["is_solved"]; got != false {
|
||||
t.Fatalf("api[1].body.is_solved = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -25,7 +26,46 @@ const (
|
||||
secureLabelOperationUpdate secureLabelOperation = "update"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
type secureLabelResourceKind struct {
|
||||
Type string
|
||||
Path string
|
||||
}
|
||||
|
||||
// secureLabelResourceKinds is intentionally independent from apply-permission:
|
||||
// the two endpoints accept different resource type contracts.
|
||||
var secureLabelResourceKinds = []secureLabelResourceKind{
|
||||
{Type: "doc", Path: "/doc/"},
|
||||
{Type: "sheet", Path: "/sheets/"},
|
||||
{Type: "file", Path: "/file/"},
|
||||
{Type: "wiki", Path: "/wiki/"},
|
||||
{Type: "bitable", Path: "/base/"},
|
||||
{Type: "bitable", Path: "/bitable/"},
|
||||
{Type: "docx", Path: "/docx/"},
|
||||
{Type: "mindnote", Path: "/mindnote/"},
|
||||
{Type: "slides", Path: "/slides/"},
|
||||
}
|
||||
|
||||
var secureLabelTypes = func() []string {
|
||||
types := make([]string, 0, len(secureLabelResourceKinds))
|
||||
seen := make(map[string]struct{}, len(secureLabelResourceKinds))
|
||||
for _, resourceKind := range secureLabelResourceKinds {
|
||||
if _, ok := seen[resourceKind.Type]; ok {
|
||||
continue
|
||||
}
|
||||
seen[resourceKind.Type] = struct{}{}
|
||||
types = append(types, resourceKind.Type)
|
||||
}
|
||||
return types
|
||||
}()
|
||||
|
||||
func secureLabelTypeAllowed(docType string) bool {
|
||||
for _, allowedType := range secureLabelTypes {
|
||||
if docType == allowedType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
var DriveSecureLabelList = common.Shortcut{
|
||||
@@ -81,6 +121,7 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
|
||||
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
|
||||
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
|
||||
"When updating many files, serialize requests and back off on rate_limit errors.",
|
||||
},
|
||||
@@ -146,8 +187,94 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
|
||||
return params
|
||||
}
|
||||
|
||||
// resolveSecureLabelTarget owns secure-label URL inference and type errors so
|
||||
// changes to another endpoint cannot widen this command's accepted resources.
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
raw = strings.TrimSpace(raw)
|
||||
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && !secureLabelTypeAllowed(explicitType) {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(secureLabelTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
ref, ok := parseSecureLabelResourceURL(raw)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
token, docType = ref.Token, ref.Type
|
||||
if explicitType != "" && explicitType != docType {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
docType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
docType = explicitType
|
||||
}
|
||||
|
||||
if docType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(secureLabelTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validateSecureLabelToken(token); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, docType, nil
|
||||
}
|
||||
|
||||
func parseSecureLabelResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
escapedPath := parsed.EscapedPath()
|
||||
for _, resourceKind := range secureLabelResourceKinds {
|
||||
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
|
||||
continue
|
||||
}
|
||||
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
|
||||
if escapedToken == "" || strings.Contains(escapedToken, "/") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
token, err := url.PathUnescape(escapedToken)
|
||||
if err != nil || token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func validateSecureLabelToken(token string) error {
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if token == "." || strings.Contains(token, "/") {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must be a non-dot single path segment",
|
||||
).WithParam("--token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSecureLabelID trims a label id and rejects display names before the
|
||||
|
||||
@@ -159,6 +159,256 @@ func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecureLabelTarget_URLAndBareToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
explicitType string
|
||||
wantToken string
|
||||
wantType string
|
||||
}{
|
||||
{"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
|
||||
{"docx URL", "https://example.feishu.cn/docx/doxTok", "", "doxTok", "docx"},
|
||||
{"sheet URL", "https://example.feishu.cn/sheets/shtTok", "", "shtTok", "sheet"},
|
||||
{"base URL", "https://example.feishu.cn/base/basTok", "", "basTok", "bitable"},
|
||||
{"bitable URL", "https://example.feishu.cn/bitable/bitTok", "", "bitTok", "bitable"},
|
||||
{"file URL", "https://example.feishu.cn/file/boxTok", "", "boxTok", "file"},
|
||||
{"mindnote URL", "https://example.feishu.cn/mindnote/mndTok", "", "mndTok", "mindnote"},
|
||||
{"slides URL", "https://example.feishu.cn/slides/sldTok", "", "sldTok", "slides"},
|
||||
{"legacy doc URL", "https://example.feishu.cn/doc/docTok", "", "docTok", "doc"},
|
||||
{"bare token with explicit type", "doxBareTok", "docx", "doxBareTok", "docx"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolveSecureLabelTarget(tt.raw, tt.explicitType)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
if token != tt.wantToken || docType != tt.wantType {
|
||||
t.Fatalf("token/type = %q/%q, want %q/%q", token, docType, tt.wantToken, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecureLabelTarget_RejectsUnsafeOrAmbiguousTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
explicitType string
|
||||
wantParam string
|
||||
}{
|
||||
{"bare traversal token", "..", "docx", "--token"},
|
||||
{"bare dot token", ".", "docx", "--token"},
|
||||
{"URL traversal token", "https://example.feishu.cn/docx/../victim", "", "--token"},
|
||||
{"marker outside resource root", "https://example.feishu.cn/share/docx/doxUnexpected", "", "--token"},
|
||||
{"encoded path separator", "https://example.feishu.cn/docx/doxTarget%2Fother", "", "--token"},
|
||||
{"encoded fragment separator", "https://example.feishu.cn/docx/doxTarget%23other", "", "--token"},
|
||||
{"conflicting URL type", "https://example.feishu.cn/docx/doxTok", "wiki", "--type"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := resolveSecureLabelTarget(tt.raw, tt.explicitType)
|
||||
if err == nil {
|
||||
t.Fatalf("resolveSecureLabelTarget(%q, %q) unexpectedly succeeded", tt.raw, tt.explicitType)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecureLabelTarget_RejectsInvalidInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "empty token",
|
||||
raw: " \t ",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "apps page URL is unsupported",
|
||||
raw: "https://example.feishu.cn/page/appMetaTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "could not infer token from URL",
|
||||
},
|
||||
{
|
||||
name: "bare token requires type",
|
||||
raw: "doxBareTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required when --token is a bare token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolveSecureLabelTarget(tt.raw, "")
|
||||
if err == nil {
|
||||
t.Fatal("resolve target error = nil, want validation error")
|
||||
}
|
||||
if token != "" || docType != "" {
|
||||
t.Fatalf("token/type = %q/%q, want empty values", token, docType)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %q/%q, want %q/%q",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
errs.CategoryValidation,
|
||||
errs.SubtypeInvalidArgument,
|
||||
)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want message containing %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RejectsAppsTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "apps page URL",
|
||||
args: []string{
|
||||
"--token", "https://example.feishu.cn/page/appMetaTok",
|
||||
},
|
||||
wantMessage: "could not infer token from URL",
|
||||
},
|
||||
{
|
||||
name: "explicit apps type",
|
||||
args: []string{
|
||||
"--token", "appBareTok",
|
||||
"--type", "apps",
|
||||
},
|
||||
wantMessage: `invalid value "apps" for --type`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := append([]string{
|
||||
"+secure-label-update",
|
||||
}, tt.args...)
|
||||
args = append(args,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
)
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want message containing %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RejectsURLMarkersOutsidePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "query",
|
||||
url: "https://example.feishu.cn/share?redirect=/docx/doxQueryTok",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
url: "https://example.feishu.cn/share#/docx/doxFragmentTok",
|
||||
},
|
||||
{
|
||||
name: "empty host",
|
||||
url: "https:///docx/doxNoHostTok",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", tt.url,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected URL validation error for %q", tt.url)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %q/%q, want %q/%q",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
errs.CategoryValidation,
|
||||
errs.SubtypeInvalidArgument,
|
||||
)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--token" {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, "--token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
|
||||
152
shortcuts/drive/drive_update_reply.go
Normal file
152
shortcuts/drive/drive_update_reply.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveUpdateReplyOp = driveCommentOp{
|
||||
Label: "reply update",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveUpdateReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
ReplyID string
|
||||
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
|
||||
}
|
||||
|
||||
func (s driveUpdateReplySpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"elements": driveReplyV1Elements(s.ReplyElements),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveUpdateReply replaces the content of an existing comment reply through
|
||||
// the Drive comment reply update API (PUT .../comments/:comment_id/replies/:reply_id),
|
||||
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
|
||||
var DriveUpdateReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+update-reply",
|
||||
Description: "Update the content of a comment reply on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveUpdateReplyOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID that owns the reply (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "reply-id", Desc: "reply ID to update (from drive +list-replies)", Required: true},
|
||||
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
),
|
||||
Tips: []string{
|
||||
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
|
||||
"The update replaces the whole reply content; there is no partial edit.",
|
||||
"Reply IDs come from `drive +list-replies` (items[].reply_id); updating a comment's root reply rewrites the comment body itself.",
|
||||
"Only the identity that created a reply can update it; other identities get API error 1069303 (forbidden). Use the same --as identity that created the reply.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveUpdateReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveUpdateReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveUpdateReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveUpdateReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveUpdateReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Updating reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
validate.EncodePathSegment(spec.ReplyID),
|
||||
)
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"PUT",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"reply_id": spec.ReplyID,
|
||||
"updated": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveUpdateReplySpec(runtime *common.RuntimeContext) (driveUpdateReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveUpdateReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
replyID := strings.TrimSpace(runtime.Str("reply-id"))
|
||||
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
return driveUpdateReplySpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
ReplyID: replyID,
|
||||
ReplyElements: replyElements,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDriveUpdateReplyDryRun(spec driveUpdateReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> update comment reply").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
PUT("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
|
||||
Desc("[2] Update reply content on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: update comment reply").
|
||||
PUT("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
356
shortcuts/drive/drive_update_reply_test.go
Normal file
356
shortcuts/drive/drive_update_reply_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveUpdateReplyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"更新后的回复"},{"type":"mention_user","mention_user":"ou_123"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
content := mustMapValue(t, body["content"], "request.content")
|
||||
elements := mustSliceValue(t, content["elements"], "request.content.elements")
|
||||
if len(elements) != 2 {
|
||||
t.Fatalf("len(request.content.elements) = %d, want 2", len(elements))
|
||||
}
|
||||
first := mustMapValue(t, elements[0], "request.content.elements[0]")
|
||||
if got := mustStringField(t, first, "type", "request.content.elements[0].type"); got != "text_run" {
|
||||
t.Fatalf("request element type = %q, want text_run", got)
|
||||
}
|
||||
firstText := mustMapValue(t, first["text_run"], "request.content.elements[0].text_run")
|
||||
if got := mustStringField(t, firstText, "text", "request.content.elements[0].text_run.text"); got != "更新后的回复" {
|
||||
t.Fatalf("text_run.text = %q, want 更新后的回复", got)
|
||||
}
|
||||
second := mustMapValue(t, elements[1], "request.content.elements[1]")
|
||||
if got := mustStringField(t, second, "type", "request.content.elements[1].type"); got != "person" {
|
||||
t.Fatalf("request element type = %q, want person", got)
|
||||
}
|
||||
secondPerson := mustMapValue(t, second["person"], "request.content.elements[1].person")
|
||||
if got := mustStringField(t, secondPerson, "user_id", "request.content.elements[1].person.user_id"); got != "ou_123" {
|
||||
t.Fatalf("person.user_id = %q, want ou_123", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
|
||||
t.Fatalf("reply_id = %q, want reply_2", got)
|
||||
}
|
||||
if got := data["updated"]; got != true {
|
||||
t.Fatalf("updated = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyExecuteViaWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "sheetFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "sheet" {
|
||||
t.Errorf("file_type = %q, want sheet", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"updated from wiki"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
|
||||
t.Fatalf("file_type = %q, want sheet", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reply-update endpoint does not declare the 100-element cap (only
|
||||
// reply-create does), so +update-reply must NOT reject >100 elements locally.
|
||||
func TestDriveUpdateReplyDoesNotCapElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 101)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("+update-reply must not cap element count locally, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "unsafe reply id",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "../reply",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "empty reply id",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", " ",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "--reply-id must not be empty",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "invalid content json",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `not-json`,
|
||||
},
|
||||
wantErr: "--content is not valid JSON",
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "reply update supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "no permission to edit reply",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "no permission to edit reply") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_token": "tokenOnly"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"更新后的回复"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "PUT" {
|
||||
t.Fatalf("api[0].method = %q, want PUT", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[0].url = %q, want resolved path segments", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
content := mustMapValue(t, body["content"], "api[0].body.content")
|
||||
elements := mustSliceValue(t, content["elements"], "api[0].body.content.elements")
|
||||
if len(elements) != 1 {
|
||||
t.Fatalf("api[0].body.content.elements length = %d, want 1", len(elements))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PUT" {
|
||||
t.Fatalf("api[1].method = %q, want PUT", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveCover,
|
||||
DriveAddComment,
|
||||
DriveListComments,
|
||||
DriveBatchQueryComments,
|
||||
DriveResolveComment,
|
||||
DriveRestoreComment,
|
||||
DriveAddReply,
|
||||
DriveListReplies,
|
||||
DriveUpdateReply,
|
||||
DriveDeleteReply,
|
||||
DriveReactReply,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
@@ -32,6 +40,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -23,6 +23,14 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+cover",
|
||||
"+add-comment",
|
||||
"+list-comments",
|
||||
"+batch-query-comments",
|
||||
"+resolve-comment",
|
||||
"+restore-comment",
|
||||
"+add-reply",
|
||||
"+list-replies",
|
||||
"+update-reply",
|
||||
"+delete-reply",
|
||||
"+react-reply",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
@@ -39,6 +47,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -14,6 +14,9 @@ func Shortcuts() []common.Shortcut {
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardExport,
|
||||
WhiteboardQuery,
|
||||
WhiteboardNodeCreate,
|
||||
WhiteboardNodeUpdate,
|
||||
WhiteboardNodeDelete,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
shortcuts/whiteboard/whiteboard_node_common.go
Normal file
145
shortcuts/whiteboard/whiteboard_node_common.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
type whiteboardNodeBatchPayload struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
|
||||
func parseWhiteboardNodeBatchPayload(raw []byte, requireID bool) (whiteboardNodeBatchPayload, error) {
|
||||
var payload whiteboardNodeBatchPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).
|
||||
WithParam("--source").
|
||||
WithCause(err)
|
||||
}
|
||||
if len(payload.Nodes) == 0 {
|
||||
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, `--source must include non-empty "nodes"`).
|
||||
WithParam("--source")
|
||||
}
|
||||
if requireID {
|
||||
for i, node := range payload.Nodes {
|
||||
id, ok := node["id"].(string)
|
||||
if !ok || strings.TrimSpace(id) == "" {
|
||||
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "nodes[%d].id must be a non-empty string", i).
|
||||
WithParam("--source")
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWhiteboardNodeIDs(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids is required").
|
||||
WithParam("--node-ids")
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids item %d must not be empty", i+1).
|
||||
WithParam("--node-ids")
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate node id %q", id).
|
||||
WithParam("--node-ids")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateOptionalWhiteboardNodeIdempotentToken(raw string) error {
|
||||
if err := common.RejectDangerousCharsTyped("--idempotent-token", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw != "" && len(raw) < 10 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").
|
||||
WithParam("--idempotent-token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callWhiteboardNodeWrite(ctx context.Context, runtime *common.RuntimeContext, method, apiPath string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
Body: body,
|
||||
QueryParams: whiteboardNodeQueryParams(params),
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, classifyErr := runtime.ClassifyAPIResponse(resp)
|
||||
if classifyErr == nil {
|
||||
return data, nil
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return data, classifyErr
|
||||
}
|
||||
if isWhiteboardNodeNonObjectSuccess(classifyErr, resp) {
|
||||
return nil, nil
|
||||
}
|
||||
return data, classifyErr
|
||||
}
|
||||
|
||||
func whiteboardNodeQueryParams(params map[string]interface{}) larkcore.QueryParams {
|
||||
query := make(larkcore.QueryParams)
|
||||
for key, value := range params {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
for _, item := range typed {
|
||||
query.Add(key, item)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
query.Add(key, whiteboardNodeQueryValue(item))
|
||||
}
|
||||
default:
|
||||
query.Set(key, whiteboardNodeQueryValue(value))
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func whiteboardNodeQueryValue(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func isWhiteboardNodeNonObjectSuccess(err error, resp *larkcore.ApiResp) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
return false
|
||||
}
|
||||
result, parseErr := client.ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
return false
|
||||
}
|
||||
_, isObject := result.(map[string]interface{})
|
||||
return !isObject
|
||||
}
|
||||
150
shortcuts/whiteboard/whiteboard_node_common_test.go
Normal file
150
shortcuts/whiteboard/whiteboard_node_common_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShortcutsIncludesWhiteboardNodeCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+export",
|
||||
"+query",
|
||||
"+node-create",
|
||||
"+node-update",
|
||||
"+node-delete",
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(got))
|
||||
for _, shortcut := range got {
|
||||
if seen[shortcut.Command] {
|
||||
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
}
|
||||
|
||||
for _, command := range want {
|
||||
if !seen[command] {
|
||||
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_MissingNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{}`), false)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_EmptyNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[]}`), false)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_InvalidJSONPreservesCause(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`not-json`), false)
|
||||
assertValidationParam(t, err, "--source", true)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_RequireIDMissingID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"text":{"text":"x"}}]}`), true)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_RequireIDBlankID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":" ","text":{"text":"x"}}]}`), true)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_PreservesArbitraryFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":"node-1","type":"shape","custom":{"x":1},"points":[1,2]}]}`), true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWhiteboardNodeBatchPayload() error = %v", err)
|
||||
}
|
||||
if len(payload.Nodes) != 1 {
|
||||
t.Fatalf("len(payload.Nodes) = %d, want 1", len(payload.Nodes))
|
||||
}
|
||||
node := payload.Nodes[0]
|
||||
if got := node["id"]; got != "node-1" {
|
||||
t.Errorf("node[id] = %v, want node-1", got)
|
||||
}
|
||||
if got := node["type"]; got != "shape" {
|
||||
t.Errorf("node[type] = %v, want shape", got)
|
||||
}
|
||||
custom, ok := node["custom"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node[custom] = %T, want map[string]interface{}", node["custom"])
|
||||
}
|
||||
if got := custom["x"]; got != float64(1) {
|
||||
t.Errorf("node[custom][x] = %v, want 1", got)
|
||||
}
|
||||
points, ok := node["points"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node[points] = %T, want []interface{}", node["points"])
|
||||
}
|
||||
if len(points) != 2 || points[0] != float64(1) || points[1] != float64(2) {
|
||||
t.Errorf("node[points] = %#v, want [1 2]", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_TrimsItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ids, err := parseWhiteboardNodeIDs(" nodeA, nodeB ,nodeC ")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWhiteboardNodeIDs() error = %v", err)
|
||||
}
|
||||
want := []string{"nodeA", "nodeB", "nodeC"}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("len(ids) = %d, want %d", len(ids), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if ids[i] != want[i] {
|
||||
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_RejectsEmptyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeIDs(" ")
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_RejectsEmptyItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeIDs("nodeA, ,nodeB")
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_RejectsDuplicateIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeIDs("nodeA,nodeB,nodeA")
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestValidateOptionalWhiteboardNodeIdempotentToken_TooShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateOptionalWhiteboardNodeIdempotentToken("short")
|
||||
assertValidationParam(t, err, "--idempotent-token", false)
|
||||
}
|
||||
138
shortcuts/whiteboard/whiteboard_node_create.go
Normal file
138
shortcuts/whiteboard/whiteboard_node_create.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var wbNodeCreateScopes = []string{"board:whiteboard:node:create"}
|
||||
var wbNodeCreateAuthTypes = []string{"user", "bot"}
|
||||
var wbNodeCreateFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to create nodes in. You need edit permission on the whiteboard.", Required: true},
|
||||
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array.`, Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "idempotent-token", Desc: "idempotent token to make create requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
|
||||
}
|
||||
|
||||
type whiteboardNodeCreateReq struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
|
||||
func wbNodeCreateValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
|
||||
return err
|
||||
}
|
||||
|
||||
func wbNodeCreateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
POST(wbNodeCreateDryRunURL(runtime.Str("whiteboard-token"))).
|
||||
Body(whiteboardNodeCreateReq{Nodes: payload.Nodes}).
|
||||
Desc("create nodes in the whiteboard.")
|
||||
if params := wbNodeCreateParams(runtime); len(params) > 0 {
|
||||
dry.Params(params)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func wbNodeCreateExecute(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
wbNodeCreateURL(runtime.Str("whiteboard-token")),
|
||||
wbNodeCreateParams(runtime),
|
||||
whiteboardNodeCreateReq{Nodes: payload.Nodes},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodeIDs, err := whiteboardNodeCreateIDs(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData := map[string]string{}
|
||||
if nodeIDs != nil {
|
||||
outData["ids"] = strings.Join(nodeIDs, ",")
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "Create whiteboard nodes success")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbNodeCreateURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token))
|
||||
}
|
||||
|
||||
func wbNodeCreateDryRunURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))
|
||||
}
|
||||
|
||||
func wbNodeCreateParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := runtime.Str("idempotent-token"); token != "" {
|
||||
params["client_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func whiteboardNodeCreateIDs(data map[string]interface{}) ([]string, error) {
|
||||
switch raw := data["ids"].(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, value := range raw {
|
||||
id, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids[%d] must be a string", i)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
case []string:
|
||||
return append([]string(nil), raw...), nil
|
||||
default:
|
||||
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids must be an array of strings")
|
||||
}
|
||||
}
|
||||
|
||||
// WhiteboardNodeCreate registers the `whiteboard +node-create` shortcut.
|
||||
var WhiteboardNodeCreate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+node-create",
|
||||
Description: "Create nodes in an existing whiteboard.",
|
||||
Risk: "write",
|
||||
Scopes: wbNodeCreateScopes,
|
||||
AuthTypes: wbNodeCreateAuthTypes,
|
||||
Flags: wbNodeCreateFlags,
|
||||
Validate: wbNodeCreateValidate,
|
||||
DryRun: wbNodeCreateDryRun,
|
||||
Execute: wbNodeCreateExecute,
|
||||
}
|
||||
173
shortcuts/whiteboard/whiteboard_node_create_test.go
Normal file
173
shortcuts/whiteboard/whiteboard_node_create_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeCreateValidate_InvalidSourceTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"source": "not-json",
|
||||
}, nil)
|
||||
|
||||
err := wbNodeCreateValidate(context.Background(), rt)
|
||||
assertValidationParam(t, err, "--source", true)
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"idempotent-token": "create-token-12345",
|
||||
"source": `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
|
||||
}, nil)
|
||||
|
||||
dryRun := wbNodeCreateDryRun(context.Background(), rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("wbNodeCreateDryRun() returned nil")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != "POST" {
|
||||
t.Fatalf("method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes" {
|
||||
t.Fatalf("url = %q, want node-create URL", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Params["client_token"] != "create-token-12345" {
|
||||
t.Fatalf("params.client_token = %#v, want create-token-12345", got.API[0].Params["client_token"])
|
||||
}
|
||||
nodes, ok := got.API[0].Body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != 1 {
|
||||
t.Fatalf("body.nodes = %#v, want one node", got.API[0].Body["nodes"])
|
||||
}
|
||||
node, ok := nodes[0].(map[string]interface{})
|
||||
if !ok || node["type"] != "composite_shape" {
|
||||
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
|
||||
}
|
||||
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateExecute_PostsNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
|
||||
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
nodes, ok := body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != 1 {
|
||||
t.Fatalf("body.nodes = %#v, want one node; body=%s", body["nodes"], string(stub.CapturedBody))
|
||||
}
|
||||
node, ok := nodes[0].(map[string]interface{})
|
||||
if !ok || node["type"] != "composite_shape" {
|
||||
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
|
||||
}
|
||||
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ids": "node-1"`) {
|
||||
t.Fatalf("stdout=%s, want ids node-1", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateExecute_AllowsMissingIDs(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
|
||||
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateExecute_RejectsMalformedIDs(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []interface{}{"node-1", 2},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
|
||||
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed ids error, got nil")
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError", err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
112
shortcuts/whiteboard/whiteboard_node_delete.go
Normal file
112
shortcuts/whiteboard/whiteboard_node_delete.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var wbNodeDeleteScopes = []string{"board:whiteboard:node:delete"}
|
||||
var wbNodeDeleteAuthTypes = []string{"user", "bot"}
|
||||
var wbNodeDeleteFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to delete nodes from. You need edit permission on the whiteboard.", Required: true},
|
||||
{Name: "node-ids", Desc: "comma-separated whiteboard node IDs to delete.", Required: true},
|
||||
{Name: "idempotent-token", Desc: "idempotent token to make delete requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
|
||||
}
|
||||
|
||||
type whiteboardNodeDeleteReq struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
func wbNodeDeleteValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func wbNodeDeleteDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse node ids failed: " + err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
DELETE(wbNodeDeleteDryRunURL(runtime.Str("whiteboard-token"))).
|
||||
Body(whiteboardNodeDeleteReq{IDs: ids}).
|
||||
Desc("delete nodes from the whiteboard.")
|
||||
if params := wbNodeDeleteParams(runtime); len(params) > 0 {
|
||||
dry.Params(params)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func wbNodeDeleteExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := callWhiteboardNodeWrite(
|
||||
ctx,
|
||||
runtime,
|
||||
http.MethodDelete,
|
||||
wbNodeDeleteURL(runtime.Str("whiteboard-token")),
|
||||
wbNodeDeleteParams(runtime),
|
||||
whiteboardNodeDeleteReq{IDs: ids},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ids": strings.Join(ids, ","),
|
||||
"count": len(ids),
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d nodes deleted.\n", len(ids))
|
||||
fmt.Fprintf(w, "Delete whiteboard nodes success")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbNodeDeleteURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(token))
|
||||
}
|
||||
|
||||
func wbNodeDeleteDryRunURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))
|
||||
}
|
||||
|
||||
func wbNodeDeleteParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := runtime.Str("idempotent-token"); token != "" {
|
||||
params["client_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// WhiteboardNodeDelete registers the `whiteboard +node-delete` shortcut.
|
||||
var WhiteboardNodeDelete = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+node-delete",
|
||||
Description: "Delete nodes from an existing whiteboard.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: wbNodeDeleteScopes,
|
||||
AuthTypes: wbNodeDeleteAuthTypes,
|
||||
Flags: wbNodeDeleteFlags,
|
||||
Validate: wbNodeDeleteValidate,
|
||||
DryRun: wbNodeDeleteDryRun,
|
||||
Execute: wbNodeDeleteExecute,
|
||||
}
|
||||
118
shortcuts/whiteboard/whiteboard_node_delete_test.go
Normal file
118
shortcuts/whiteboard/whiteboard_node_delete_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeDeleteValidate_InvalidNodeIDsTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"node-ids": "nodeA,,nodeB",
|
||||
}, nil)
|
||||
|
||||
err := wbNodeDeleteValidate(context.Background(), rt)
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeDeleteMetadata_RiskHighRiskWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if WhiteboardNodeDelete.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk = %q, want high-risk-write", WhiteboardNodeDelete.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"node-ids": "nodeA,nodeB",
|
||||
"idempotent-token": "delete-token-12345",
|
||||
}, nil)
|
||||
|
||||
dryRun := wbNodeDeleteDryRun(context.Background(), rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("wbNodeDeleteDryRun() returned nil")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != "DELETE" {
|
||||
t.Fatalf("method = %q, want DELETE", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_delete" {
|
||||
t.Fatalf("url = %q, want masked node-delete URL", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Params["client_token"] != "delete-token-12345" {
|
||||
t.Fatalf("params.client_token = %#v, want delete-token-12345", got.API[0].Params["client_token"])
|
||||
}
|
||||
ids, ok := got.API[0].Body["ids"].([]interface{})
|
||||
if !ok || len(ids) != 2 {
|
||||
t.Fatalf("body.ids = %#v, want two ids", got.API[0].Body["ids"])
|
||||
}
|
||||
if ids[0] != "nodeA" || ids[1] != "nodeB" {
|
||||
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeDeleteExecute_PostsIDs(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
args := []string{"+node-delete", "--whiteboard-token", "test-board", "--node-ids", "nodeA,nodeB"}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeDelete, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
ids, ok := body["ids"].([]interface{})
|
||||
if !ok || len(ids) != 2 {
|
||||
t.Fatalf("body.ids = %#v, want two ids; body=%s", body["ids"], string(stub.CapturedBody))
|
||||
}
|
||||
if ids[0] != "nodeA" || ids[1] != "nodeB" {
|
||||
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
|
||||
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
|
||||
}
|
||||
}
|
||||
141
shortcuts/whiteboard/whiteboard_node_update.go
Normal file
141
shortcuts/whiteboard/whiteboard_node_update.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var wbNodeUpdateScopes = []string{"board:whiteboard:node:update"}
|
||||
var wbNodeUpdateAuthTypes = []string{"user", "bot"}
|
||||
var wbNodeUpdateFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update nodes in. You need edit permission on the whiteboard.", Required: true},
|
||||
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array. Each node must include "id"; the batch_update body sends the full nodes array.`, Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "idempotent-token", Desc: "idempotent token to make batch update requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
|
||||
}
|
||||
|
||||
func wbNodeUpdateValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
|
||||
return err
|
||||
}
|
||||
|
||||
func wbNodeUpdateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
PUT(wbNodeBatchUpdateDryRunURL(runtime.Str("whiteboard-token"))).
|
||||
Body(whiteboardNodeBatchUpdateBody(payload)).
|
||||
Desc("batch update nodes in the whiteboard.")
|
||||
if params := wbNodeUpdateParams(runtime); len(params) > 0 {
|
||||
dry.Params(params)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func wbNodeUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPut,
|
||||
wbNodeBatchUpdateURL(runtime.Str("whiteboard-token")),
|
||||
wbNodeUpdateParams(runtime),
|
||||
whiteboardNodeBatchUpdateBody(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updatedNodeIDs, err := whiteboardNodeUpdateIDs(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ids": strings.Join(updatedNodeIDs, ","),
|
||||
"count": len(updatedNodeIDs),
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d nodes updated.\n", len(updatedNodeIDs))
|
||||
fmt.Fprintf(w, "Update whiteboard nodes success")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbNodeBatchUpdateURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", url.PathEscape(token))
|
||||
}
|
||||
|
||||
func wbNodeBatchUpdateDryRunURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", common.MaskToken(url.PathEscape(token)))
|
||||
}
|
||||
|
||||
func wbNodeUpdateParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := runtime.Str("idempotent-token"); token != "" {
|
||||
params["client_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func whiteboardNodeBatchUpdateBody(payload whiteboardNodeBatchPayload) map[string]interface{} {
|
||||
return map[string]interface{}{"nodes": payload.Nodes}
|
||||
}
|
||||
|
||||
func whiteboardNodeUpdateIDs(data map[string]interface{}) ([]string, error) {
|
||||
switch raw := data["ids"].(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, value := range raw {
|
||||
id, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids[%d] must be a string", i)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
case []string:
|
||||
return append([]string(nil), raw...), nil
|
||||
default:
|
||||
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids must be an array of strings")
|
||||
}
|
||||
}
|
||||
|
||||
// WhiteboardNodeUpdate registers the `whiteboard +node-update` shortcut.
|
||||
var WhiteboardNodeUpdate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+node-update",
|
||||
Description: "Update nodes in an existing whiteboard.",
|
||||
Risk: "write",
|
||||
Scopes: wbNodeUpdateScopes,
|
||||
AuthTypes: wbNodeUpdateAuthTypes,
|
||||
Flags: wbNodeUpdateFlags,
|
||||
Tips: []string{
|
||||
`Pass --source as JSON with a non-empty "nodes" array; each node must include "id".`,
|
||||
`Execution sends one whiteboard.node batch_update request and preserves node ids in the request body.`,
|
||||
`Use --idempotent-token for retry-safe batch_update requests; the token is sent as client_token only when provided.`,
|
||||
},
|
||||
Validate: wbNodeUpdateValidate,
|
||||
DryRun: wbNodeUpdateDryRun,
|
||||
Execute: wbNodeUpdateExecute,
|
||||
}
|
||||
239
shortcuts/whiteboard/whiteboard_node_update_test.go
Normal file
239
shortcuts/whiteboard/whiteboard_node_update_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeUpdateValidate_SourceMissingIDTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"source": `{"nodes":[{"type":"text","text":{"content":"hello"}}]}`,
|
||||
}, nil)
|
||||
|
||||
err := wbNodeUpdateValidate(context.Background(), rt)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"idempotent-token": "update-token-12345",
|
||||
"source": `{"nodes":[` +
|
||||
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
|
||||
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
|
||||
`]}`,
|
||||
}, nil)
|
||||
|
||||
dryRun := wbNodeUpdateDryRun(context.Background(), rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("wbNodeUpdateDryRun() returned nil")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != "PUT" {
|
||||
t.Fatalf("method = %q, want PUT", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_update" {
|
||||
t.Fatalf("url = %q, want masked batch_update URL", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Params["client_token"] != "update-token-12345" {
|
||||
t.Fatalf("params.client_token = %#v, want update-token-12345", got.API[0].Params["client_token"])
|
||||
}
|
||||
nodes, ok := got.API[0].Body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != 2 {
|
||||
t.Fatalf("body.nodes = %#v, want two nodes", got.API[0].Body["nodes"])
|
||||
}
|
||||
wantText := []string{"hello A", "hello B"}
|
||||
for i := range nodes {
|
||||
node, ok := nodes[i].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("body.nodes[%d] = %T, want map; nodes=%#v", i, nodes[i], nodes)
|
||||
}
|
||||
if node["id"] != []string{"nodeA", "nodeB"}[i] {
|
||||
t.Fatalf("body.nodes[%d].id = %#v", i, node["id"])
|
||||
}
|
||||
text, ok := node["text"].(map[string]interface{})
|
||||
if !ok || text["content"] != wantText[i] {
|
||||
t.Fatalf("body.nodes[%d].text = %#v, want content %q", i, node["text"], wantText[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateExecute_BatchUpdatesNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
var capturedQuery string
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"nodeA", "nodeB"},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `{"nodes":[` +
|
||||
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
|
||||
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
|
||||
`]}`
|
||||
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source, "--idempotent-token", "update-token-12345"}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
assertNodeBatchUpdateCapturedBody(t, stub.CapturedBody, []string{"hello A", "hello B"})
|
||||
if !strings.Contains(capturedQuery, "client_token=update-token-12345") {
|
||||
t.Fatalf("query = %q, want client_token", capturedQuery)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
|
||||
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"count": 2`) {
|
||||
t.Fatalf("stdout=%s, want count 2", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateExecute_WithoutIdempotentTokenOmitsClientToken(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
var capturedQuery string
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"nodeA"},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}}]}`
|
||||
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if capturedQuery != "" {
|
||||
t.Fatalf("query = %q, want empty when --idempotent-token is absent", capturedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateExecute_BatchFailureReturnsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1254001,
|
||||
"msg": "node not found",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"nodes":[` +
|
||||
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
|
||||
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
|
||||
`]}`
|
||||
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch update failure error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("errs.ProblemOf returned false for %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error type = %T, want *errs.APIError reachable via errors.As", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateTips_MentionTemporaryNonAtomicBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tips := strings.Join(WhiteboardNodeUpdate.Tips, "\n")
|
||||
for _, want := range []string{"batch_update", "client_token", "one whiteboard.node batch_update request"} {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips = %q, want substring %q", tips, want)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"fans out", "non-atomic", "Temporary behavior"} {
|
||||
if strings.Contains(tips, banned) {
|
||||
t.Fatalf("tips = %q, should not contain old fan-out wording %q", tips, banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNodeBatchUpdateCapturedBody(t *testing.T, raw []byte, wantContent []string) {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(raw))
|
||||
}
|
||||
nodes, ok := body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != len(wantContent) {
|
||||
t.Fatalf("body.nodes = %#v, want %d nodes; body=%s", body["nodes"], len(wantContent), string(raw))
|
||||
}
|
||||
for i, rawNode := range nodes {
|
||||
node, ok := rawNode.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("body.nodes[%d] = %T, want map; body=%s", i, rawNode, string(raw))
|
||||
}
|
||||
if _, exists := node["id"]; !exists {
|
||||
t.Fatalf("body.nodes[%d].id absent; body=%s", i, string(raw))
|
||||
}
|
||||
text, ok := node["text"].(map[string]interface{})
|
||||
if !ok || text["content"] != wantContent[i] {
|
||||
t.Fatalf("body.nodes[%d].text = %#v, want content %q; body=%s", i, node["text"], wantContent[i], string(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -38,6 +39,7 @@ metadata:
|
||||
进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID;不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`。
|
||||
|
||||
- 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "<url>" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。
|
||||
- Base/Wiki URL 的 `table=` query 参数实际表示当前选中的顶层 block,可能是数据表、仪表盘或 workflow;不要按参数名自行当成 `table_id`。以 `+url-resolve` 返回的 `block_type` 以及 `table_id` / `dashboard_id` / `workflow_id` 为准;`selection_source=url_query` 只说明 URL 当前选中了该 block,不代表它覆盖用户明确点名的目标。若用户点名的 dashboard 与 `block_name` 不一致,先用 `+dashboard-list` 按名称匹配;若只返回中性 `block_id`,按 hint 用 `+base-block-list` 确认类型。
|
||||
- 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "<keyword>" --as user`;`--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。
|
||||
- 文档嵌入 Base 标签:直接读取 `<bitable>` / `<base_refer>` 的 `token` 作为 `--base-token`,`table-id` 作为 `--table-id`,`view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`。
|
||||
- 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base;用户要新建时才用 `+base-create`。
|
||||
@@ -48,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 的列出、详情、创建、重命名和删除 |
|
||||
@@ -57,13 +60,14 @@ metadata:
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md));其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+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) |
|
||||
| 其他表单管理 | `+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);系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
@@ -115,7 +119,11 @@ 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`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
@@ -146,13 +154,14 @@ metadata:
|
||||
## 保留 Reference
|
||||
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT;`+data-query` 的 `filters` 结构是独立对象 DSL,不使用公共 tuple filter 协议
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT;不适用于 `+data-query`
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [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):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
|
||||
@@ -79,16 +79,23 @@ lark-cli base +data-query \
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--dsl <json>` | 是 | LiteQuery Protocol JSON DSL 查询语句 |
|
||||
|
||||
## 如何从链接中提取参数
|
||||
## 如何从链接中解析参数
|
||||
|
||||
用户通常会提供如下 URL:
|
||||
|
||||
```
|
||||
https://example.feishu.cn/base/<base_token>?table=<table_id>
|
||||
```text
|
||||
https://example.feishu.cn/base/<base_token>?table=<block_id>
|
||||
```
|
||||
|
||||
- `--base-token`:取 `/base/` 后面的字符串
|
||||
- DSL 中的 `tableId`:取 `table=` 后面的值
|
||||
不要直接把 URL 中的 `table=` 当成数据表 ID。它表示当前选中的 Base 顶层块,可能是数据表、仪表盘、工作流、文件夹或文档。先解析链接:
|
||||
|
||||
```bash
|
||||
lark-cli base +url-resolve --url "<url>" --as user
|
||||
```
|
||||
|
||||
- `--base-token`:使用返回的 `base_token`
|
||||
- 仅当返回的 `block_type` 为 `table` 时,DSL 中的 `tableId` 才使用返回的 `table_id`
|
||||
- 如果返回的是其他块类型,按 `hint.next_step` 继续处理;如果只返回中性的 `block_id`,先用 `+base-block-list` 确认块类型,再选择实际要查询的数据表
|
||||
|
||||
## API 入参详情
|
||||
|
||||
|
||||
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Base Filter 条件结构(公共协议)
|
||||
|
||||
Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构,本文件是其公共协议(SSOT)。
|
||||
|
||||
## 0. 适用范围
|
||||
|
||||
本协议只适用于以下场景:
|
||||
|
||||
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
|
||||
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
|
||||
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
|
||||
|
||||
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
|
||||
|
||||
## 1. 顶层结构
|
||||
|
||||
- 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["状态", "intersects", ["Doing"]],
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]],
|
||||
["截止时间", "empty"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
清空写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
value 类型取决于条件引用对象(字段 / 题目)的类型。
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 5. 参考
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"type":"text","title":"您的姓名是?","required":true},
|
||||
{"type":"text","title":"您的联系方式是?","required":false}
|
||||
]'
|
||||
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
|
||||
|
||||
# 添加单选题(带选项)
|
||||
lark-cli base +form-questions-create \
|
||||
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
|
||||
|
||||
# 添加带显隐条件(visible_rule)的问题:当「是否需要发票」选择「是」时才显示「发票抬头」
|
||||
lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
|
||||
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
|
||||
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
|
||||
| `style` | 否 | 字段样式配置(见下方说明) |
|
||||
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
|
||||
|
||||
### `style` 字段说明
|
||||
|
||||
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
|
||||
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
|
||||
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` |
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
|
||||
|
||||
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
|
||||
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
|
||||
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
|
||||
- 引用的题目必须真实存在,否则会报错。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["是否需要发票", "==", "是"],
|
||||
["报销金额", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
返回创建成功的问题列表:
|
||||
@@ -108,11 +137,15 @@ 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` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user