Compare commits

..

2 Commits

Author SHA1 Message Date
zhouyue.z
58d488e379 test(base): cover wiki url resolve block errors 2026-07-31 12:56:27 +08:00
zhouyue.z
236bf7d253 fix(base): surface url resolve block list errors 2026-07-31 12:18:18 +08:00
45 changed files with 255 additions and 2992 deletions

View File

@@ -65,17 +65,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
// 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)
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,17 +128,6 @@ 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)
}

View File

@@ -1,71 +0,0 @@
// 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/clearbody {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"))
}

View File

@@ -1,75 +0,0 @@
// 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)
}

View File

@@ -1,105 +0,0 @@
// 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时不带 valuettl_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_bytesCLI 算),未命中时 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"))
}
}

View File

@@ -1,357 +0,0 @@
// 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_HitPrettypretty 把 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无 valuettl_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_PrettyNonJSONFallbackpretty 下 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 numbercacheInt不得随 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 numbercacheInt
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=1pretty 打 "✓ 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=0pretty 措辞区分。
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_DryRunDELETE 方法、/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=128pretty 打 "✓ 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_RequiresConfirmationhigh-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_DryRunBodyWithEnvdry-run POST /cache/clearbody 带 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
// 复用本包规范的 dryRunAPIEnvelopeapi 现嵌在 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]
}

View File

@@ -1,99 +0,0 @@
// 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 家族的旧名 --envenv 值经 dbEnv 读、
// 经 dbEnvParams 注入——get/delete 放 queryclear 放 body省略即服务端自动选分支
// appCachePath 返回缓存单 key 读/删 URLcacheGET 读、DELETE 删,靠方法区分)。
func appCachePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
}
// appCacheClearPath 返回清空指定环境缓存 URLcache/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)
}

View File

@@ -64,9 +64,6 @@ func Shortcuts() []common.Shortcut {
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsCacheGet,
AppsCacheDelete,
AppsCacheClear,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,

View File

@@ -20,14 +20,13 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 cacheget/delete/clear
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 82
func TestAppsShortcuts_Returns82(t *testing.T) {
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79
func TestAppsShortcuts_Returns79(t *testing.T) {
got := Shortcuts()
if len(got) != 82 {
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
}
}

View File

@@ -195,7 +195,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
switch classifyBaseURL(parsed) {
case "base_url":
out := resolveBaseURL(parsed)
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
if err := enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed)); err != nil {
return err
}
runtime.OutFormat(out, nil, nil)
return nil
case "wiki_url":
@@ -205,7 +207,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
}
selection := resolveBaseURLSelection(parsed)
applyBaseURLSelection(out, selection)
enrichBaseResolveHint(runtime, out, selection)
if err := enrichBaseResolveHint(runtime, out, selection); err != nil {
return err
}
runtime.OutFormat(out, nil, nil)
return nil
case "record_share_url":
@@ -422,15 +426,19 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
}
}
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) error {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
if baseToken == "" || selectedBlockID == "" {
out["hint"] = resolveHint("", nil)
return
return nil
}
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID)
if err != nil {
return err
}
if found {
out["block_type"] = block.Type
if block.Name != "" {
out["block_name"] = block.Name
@@ -467,10 +475,11 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
default:
out["hint"] = resolveUnknownBlockHint()
}
return
return nil
}
out["hint"] = resolveUnknownBlockHint()
return nil
}
type resolvedBaseBlock struct {

View File

@@ -4,6 +4,7 @@
package base
import (
"errors"
"net/http"
"strings"
"testing"
@@ -67,7 +68,10 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
})
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
@@ -96,6 +100,15 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
}
})
t.Run("block list error is returned", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListScopeErrorStub("bas123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "bot",
}, factory, stdout)
assertBaseBlockReadPermissionError(t, err, stdout)
})
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
@@ -269,6 +282,22 @@ func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}
}
}
func baseBlockListScopeErrorStub(baseToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
Body: map[string]interface{}{
"code": 99991672,
"msg": "access denied",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "base:block:read"},
},
},
},
}
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -309,6 +338,19 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
}
})
t.Run("bitable table coordinates return block list error", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListScopeErrorStub("bas123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew_stale",
"--as", "bot",
}, factory, stdout)
assertBaseBlockReadPermissionError(t, err, stdout)
})
t.Run("bitable with dashboard selection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
@@ -376,6 +418,33 @@ func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
}
}
func assertBaseBlockReadPermissionError(t *testing.T, err error, stdout interface {
Len() int
String() string
}) {
t.Helper()
if err == nil {
t.Fatal("expected block-list error to be returned")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeAppScopeNotApplied || p.Code != 99991672 {
t.Fatalf("unexpected problem: %#v", p)
}
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) {
t.Fatalf("expected PermissionError, got %T %v", err, err)
}
if len(permissionErr.MissingScopes) != 1 || permissionErr.MissingScopes[0] != "base:block:read" {
t.Fatalf("missing scopes=%v", permissionErr.MissingScopes)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should stay empty when block-list fails: %s", stdout.String())
}
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)

View File

@@ -6,7 +6,6 @@ package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -14,137 +13,72 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
type permApplyResourceKind struct {
Type string
Path string
// 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",
}
// 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
// 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"},
}
// 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. A URL's path and explicit --type must agree.
// optional explicit --type. Explicit --type wins over URL inference.
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, "://") {
ref, ok := parsePermApplyResourceURL(raw)
if !ok {
for _, m := range permApplyURLMarkers {
if tok, ok := extractURLToken(raw, m.Marker); ok {
token = tok
if explicitType == "" {
docType = m.Type
}
break
}
}
if token == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"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",
"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 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.
@@ -154,19 +88,16 @@ func validatePermApplyToken(token string) error {
var DriveApplyPermission = common.Shortcut{
Service: "drive",
Command: "+apply-permission",
Description: "Apply to the owner for view or edit permission on a Drive resource",
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
Risk: "write",
Scopes: []string{"docs:permission.member:apply"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or URL (docx/sheets/base/file/wiki/doc/mindnote/slides/page)", Required: true},
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", 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
@@ -178,7 +109,7 @@ var DriveApplyPermission = common.Shortcut{
}
body := buildPermApplyBody(runtime)
return common.NewDryRunAPI().
Desc("Apply to resource owner for access").
Desc("Apply to document owner for access").
POST("/open-apis/drive/v1/permissions/:token/members/apply").
Params(map[string]interface{}{"type": docType}).
Body(body).
@@ -200,7 +131,7 @@ var DriveApplyPermission = common.Shortcut{
body,
)
if err != nil {
return decoratePermApplyError(err)
return err
}
runtime.Out(data, nil)
return nil
@@ -217,34 +148,3 @@ 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 ""
}
}

View File

@@ -5,11 +5,9 @@ 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"
)
@@ -35,18 +33,6 @@ 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 {
@@ -64,7 +50,6 @@ 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
@@ -81,100 +66,15 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
}
}
func TestResolvePermApplyTarget_RejectsMalformedPageURL(t *testing.T) {
func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) {
t.Parallel()
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)
// 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)
}
}
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)
}
})
if token != "doxTok123" || docType != "wiki" {
t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType)
}
}
@@ -250,33 +150,6 @@ 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
@@ -323,11 +196,6 @@ 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"},
},
},
},
})
@@ -344,18 +212,6 @@ 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) {
@@ -379,17 +235,4 @@ 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)
}
}

View File

@@ -5,11 +5,8 @@ package drive
import (
"context"
"errors"
"fmt"
"net/http"
"path"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -19,180 +16,47 @@ 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"},
// 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"},
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 = "<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",
},
},
})
outputPath = fileToken
}
return plan.
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/files/:file_token/download").
Desc(downloadDesc).
Set("file_token", fileToken).
Set("output", outputPath)
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")
// Early path validation + overwrite check
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 err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
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
}
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 _, 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")
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
@@ -206,20 +70,6 @@ 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,

View File

@@ -639,29 +639,12 @@ func sanitizeExportFileName(name, fallback string) string {
)
name = replacer.Replace(name)
name = strings.Trim(name, ". ")
if name == "" || isWindowsReservedDeviceFileName(name) {
if 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 {

View File

@@ -58,20 +58,6 @@ 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")
}

View File

@@ -12,31 +12,19 @@ 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 {
@@ -46,15 +34,9 @@ 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
@@ -1580,613 +1562,6 @@ 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

View File

@@ -51,10 +51,9 @@ var driveMemberAddURLPathToType = []struct {
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
{"/page/", "apps"},
}
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps"}
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
const driveMemberAddBatchLimit = 10
@@ -62,7 +61,7 @@ const driveMemberAddBatchLimit = 10
var DriveMemberAdd = common.Shortcut{
Service: "drive",
Command: "+member-add",
Description: "Add a collaborator/member permission to a Drive resource",
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
Risk: "high-risk-write",
Scopes: []string{"docs:permission.member:create"},
AuthTypes: []string{"user", "bot"},
@@ -321,7 +320,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", "apps":
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
return true
default:
return false

View File

@@ -35,12 +35,10 @@ 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

View File

@@ -24,7 +24,7 @@ type driveMemberListSpec struct {
var driveMemberListTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder", "apps",
"mindnote", "minutes", "slides", "folder",
}
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
@@ -45,7 +45,6 @@ var driveMemberListURLPathToType = []struct {
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
{"/page/", "apps"},
}
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
@@ -89,7 +88,7 @@ func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType
if !ok {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
raw,
).WithParam("--token")
}
@@ -236,13 +235,13 @@ func (s driveMemberListSpec) params() map[string]interface{} {
var DriveMemberList = common.Shortcut{
Service: "drive",
Command: "+member-list",
Description: "List collaborator/member permissions on a Drive resource",
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
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: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", 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"},

View File

@@ -83,19 +83,6 @@ func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
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 {

View File

@@ -12,7 +12,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -22,68 +21,26 @@ type drivePermissionGetSettingSpec struct {
Type string
}
type drivePermissionGetSettingResourceKind struct {
Type string
CanonicalPath string
PathAliases []string
var drivePermissionGetSettingTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder",
}
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
var drivePermissionGetSettingURLPathToType = []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"},
}
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
@@ -111,7 +68,7 @@ func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePer
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",
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
rawToken,
).WithParam("--token")
}
@@ -123,8 +80,8 @@ func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePer
ref.Type,
).WithParam("--type")
}
if err := validateDrivePermissionGetSettingToken(ref.Token); err != nil {
return drivePermissionGetSettingSpec{}, err
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
}
@@ -137,61 +94,53 @@ func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePer
).WithParam("--type")
}
if err := validateDrivePermissionGetSettingToken(rawToken); err != nil {
return drivePermissionGetSettingSpec{}, err
if err := validate.ResourceName(rawToken, "--token"); err != nil {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
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") {
if err != nil || parsed.Hostname() == "" {
return common.ResourceRef{}, false
}
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
if ref, ok := parseDrivePermissionGetSettingResourceKindPath(parsed.EscapedPath(), resourceKind); ok {
return ref, true
for _, mapping := range drivePermissionGetSettingURLPathToType {
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
continue
}
token := parsed.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 drivePermissionGetSettingTypeAllowed(docType string) bool {
_, ok := findDrivePermissionGetSettingResourceKind(docType)
return ok
for _, allowed := range drivePermissionGetSettingTypes {
if docType == allowed {
return true
}
}
return false
}
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
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
return u
}
}
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
return common.BuildResourceURL("", s.Type, s.Token)
}
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
@@ -217,7 +166,8 @@ func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map
return permissionPublic, nil
}
// DrivePermissionGetSetting queries permission_public settings for a Drive resource.
// DrivePermissionGetSetting queries permission_public settings for a Drive
// document, file, wiki node, or folder.
var DrivePermissionGetSetting = common.Shortcut{
Service: "drive",
Command: "+permission-get-setting",
@@ -227,7 +177,7 @@ var DrivePermissionGetSetting = common.Shortcut{
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: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
},
Tips: []string{

View File

@@ -14,7 +14,6 @@ import (
"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"
)
@@ -84,12 +83,6 @@ func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
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 ",
@@ -111,19 +104,6 @@ func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
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 {
@@ -146,57 +126,6 @@ func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
}
}
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()
@@ -218,26 +147,6 @@ func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
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",
@@ -512,62 +421,6 @@ func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t
}
}
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()

View File

@@ -6,7 +6,6 @@ package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -26,46 +25,7 @@ const (
secureLabelOperationUpdate secureLabelOperation = "update"
)
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
}
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
var DriveSecureLabelList = common.Shortcut{
@@ -121,7 +81,6 @@ 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.",
},
@@ -187,94 +146,8 @@ 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) {
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
return resolvePermApplyTarget(raw, explicitType)
}
// normalizeSecureLabelID trims a label id and rejects display names before the

View File

@@ -159,256 +159,6 @@ 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{

View File

@@ -41,7 +41,6 @@ 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) |
| 逐条执行 SQLSELECT / 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) |

View File

@@ -1,61 +0,0 @@
# 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 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。

View File

@@ -68,7 +68,7 @@ metadata:
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | 不能直接当底层 `file_token`;优先用 `drive +inspect` 解包获取 `obj_token` |
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/page/` | `https://example.feishu.cn/page/pagcnxxxxxxxx/` | apps token | URL 路径中的 token 直接使用,资源类型为 `apps` |
| `/page/` | `https://example.feishu.cn/page/N1BWmMrqndT5ZcamAIBcnvDLnOf/` | apps token | 妙搭 apps 类型;用于评论列表时直接作为 `file_token``file_type=apps` |
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
### Wiki 链接特殊处理

View File

@@ -34,8 +34,8 @@ lark-cli drive +apply-permission \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 目标文档 token 或完整 URL`/docx/``/sheets/``/base/``/bitable/``/file/``/wiki/``/doc/``/mindnote/``/slides/``/page/` 路径里的 token 会被自动提取) |
| `--type` | 否 | 目标类型,可选值 `doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `slides` / `apps`。传 URL 时由 shortcut 自动推断;如显式传入,必须与 URL 路径类型一致。bare token 必须显式传 |
| `--token` | 是 | 目标文档 token 或完整 URL`/docx/``/sheets/``/base/``/bitable/``/file/``/wiki/``/doc/``/mindnote/``/slides/` 路径里的 token 会被自动提取) |
| `--type` | 否 | 目标类型,可选值 `doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `slides`。传 URL 时由 shortcut 自动推断bare token 必须显式传 |
| `--perm` | 是 | 申请的权限,仅支持 `view``edit`**不支持 `full_access`**CLI 侧会直接拒绝) |
| `--remark` | 否 | 备注,会显示在权限申请卡片上 |
| `--dry-run` | 否 | 仅打印请求内容,不实际发送 |
@@ -70,7 +70,7 @@ API 成功时返回空 `data`(仅 `code: 0, msg: "success"`),对应 CLI
## 与 wiki URL 的关系
传入 `/wiki/<node_token>`shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),先使用与后续权限申请相同的身份调用 `wiki +node-get --node-token '<wiki_url>' --as user --format json`(下游使用 bot 时两步都改为 `--as bot`),读取 `data.obj_token``data.obj_type`,再 bare `obj_token` 传给 `--token`、把真实 `obj_type` 传给 `--type`(例如 `data.obj_type``docx` 时使用 `--type docx`
传入 `/wiki/<node_token>`shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),自行先调 `wiki spaces get_node``obj_token + obj_type`,再 bare token + `--type docx` 调本命令
## 参考

View File

@@ -11,7 +11,7 @@
# 下载到指定路径
lark-cli drive +download --file-token boxbc_xxx --output ./report.pdf
# 只提供 token默认保存当前目录
# 只提供 token默认保存当前目录下同名文件
lark-cli drive +download --file-token boxbc_xxx
```

View File

@@ -20,8 +20,8 @@ lark-cli drive +member-add \
| 参数 | 必填 | 说明 |
|------|----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--token` | 是 | 裸 token 或完整 URL。路径支持 `/drive/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/``/page/`URL 输入可从路径推断 `--type`,裸 token 不做前缀推断 |
| `--type` | 必填 | 目标资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes` / `apps`。传 URL 时可省略;裸 token 必须显式传;若同时传 URL 和 `--type`,显式 `--type` 覆盖 URL 推断 |
| `--token` | 是 | 裸 token 或完整 URL。路径支持 `/drive/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`URL 输入可从路径推断 `--type`,裸 token 不做前缀推断 |
| `--type` | 必填 | 目标资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。传 URL 时可省略;裸 token 必须显式传;若同时传 URL 和 `--type`,显式 `--type` 覆盖 URL 推断 |
| `--member-id` | 是 | 协作者 ID逗号分隔可批量添加最多 10 个 |
| `--member-type` | 是 | member-id 的类型;支持 `email` / `openid` / `unionid` / `openchat` / `opendepartmentid` / `groupid` / `appid` / `wikispaceid`。在实际使用里,给当前应用授权仍优先推荐 bot `open_id` + `openid`。 |
| `--member-kind` | 条件必填 | 仅当 `--member-type=wikispaceid` 时填写,映射到请求 body 的 `type` 字段。取值:`wiki_space_member` / `wiki_space_viewer` / `wiki_space_editor`。其他 member-type 禁止传此参数。 |

View File

@@ -23,8 +23,8 @@ lark-cli drive +member-list \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/``/page/`。 |
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder` / `apps`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`。 |
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 |
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
| `--dry-run` | 否 | 只打印请求,不调用 API。 |

View File

@@ -21,8 +21,8 @@ lark-cli drive +permission-get-setting \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/``/page/`。 |
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder` / `apps`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`。 |
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
## 输出

View File

@@ -121,7 +121,7 @@ Risk / Structure: `R2` / `S2`
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers``unsupported_checks`
2. 发现阶段必须生成稳定 `path`。不要只保存 title同名文档必须能通过 path 或 token 区分。
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides``folder``apps`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides``folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`URL、owner、密级等 metadata 可能进入 `unsupported_checks`
5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token <folder_token> --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持。`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker不要默认展示内部 page token / cursor。

View File

@@ -274,10 +274,10 @@ N. 结尾页:[结尾文案]
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先用 Wiki shortcut 查询节点,确认 `data.obj_type == "slides"`,再用 `data.obj_token` 作为真实 presentation ID。
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先查询 wiki 节点,确认 `node.obj_type == "slides"`,再用 `node.obj_token` 作为真实 presentation ID。
```bash
lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
```
Shortcut `+replace-slide``+media-upload` 会自动解析 `/wiki/` URL手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。

View File

@@ -27,14 +27,14 @@ metadata:
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先确定下游成员操作的身份(默认 `user`;用户明确要求应用 / bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json`,从 `data.space_id` 获取空间 ID下游使用 bot 时将示例中的身份改为 `--as bot`。节点解析与后续成员操作必须使用相同身份
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>`先确定后续 `wiki +delete-space` 的身份(默认 `user`;明确要求 bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json`,读 `data.space_id`;下游使用 bot 时将示例中的身份改为 `--as bot`。解析和删除必须使用相同身份
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`
- 只知名称:`lark-cli wiki spaces list --format json`,边翻页边收集 items 并按 `name` 精确匹配;**一旦任一页累计到至少 1 条精确匹配就停止翻页**。只有当翻完所有页(`has_more=false`)仍无精确匹配时,才对已收集的全量 items 做宽松匹配(`name` trim 空格、大小写不敏感、子串包含)。
- **关键安全约束**:无论精确还是模糊,**无论命中 1 条还是多条,发起删除前都必须把候选(`name` + `space_id` + `description` + `space_type`)列给用户,由用户明确选定一个 `space_id` 再执行**。不要因为"只命中一条"就自动执行删除。
- 命中 0 条:停下来问用户是名称拼错了还是调用方无权限;**不要**自行改名字重试。
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki +node-get` 解析出 `data.space_id` 再传。
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token如果用户给的是 docx/sheet/base URL先用 `wiki +node-get --node-token <url>` 解析出 `node_token`
- `wiki +node-list` 命中 `invalid_parameters``not_found``permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
@@ -48,8 +48,6 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
获取或解析 Wiki 节点统一优先使用 `wiki +node-get`,包括只为获取 `space_id``node_token``obj_token``obj_type` 的中间步骤。只有当前 CLI 不提供该 shortcut或任务明确需要 shortcut 未输出的原始响应字段时,才回退到 `wiki spaces get_node`;回退前先运行 `lark-cli schema wiki.spaces.get_node`
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |

View File

@@ -117,16 +117,13 @@ dry-run 会展示两步调用链:
### 2. 只有知识库 URL`.../wiki/<token>`
先确定后续 `wiki +delete-space` 使用的身份:默认使用 `user`;用户明确要求应用 / bot 视角时使用 `bot`。下面展示默认 user 身份;下游使用 bot 时将两步都改为 `--as bot`。节点解析和删除必须使用相同身份。
```bash
lark-cli wiki +node-get \
--node-token '<wiki_url>' \
--as user \
lark-cli wiki spaces get_node \
--params '{"token":"<wiki_token>"}' \
--format json
```
读取 `data.space_id`。只有当前 CLI 不提供 `+node-get`,或必须读取 shortcut 未输出的原始字段时,才在查看 `lark-cli schema wiki.spaces.get_node` 后回退到原生命令
读取 `data.node.space_id`
### 3. 只有知识库名称

View File

@@ -2,16 +2,15 @@
## Metrics
- Denominator: 40 leaf commands
- Covered: 22
- Coverage: 55.0%
- Covered: 21
- Coverage: 52.5%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, downloads the file with explicit `--output` to confirm the remote content changed, and downloads again without `--output` to prove default filename resolution saves the remote name with matching bytes.
- TestDriveDownloadDryRun_DefaultNamePlansMetadataBeforeDownload / TestDriveDownloadDryRun_ExplicitOutputSkipsMetadata: dry-run coverage for `drive +download`; asserts omitted `--output` plans metadata before download for default naming, while explicit `--output` preserves the direct download-only path.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess / TestDrive_ApplyPermissionDryRunRejectsUnsafeTargets: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of invalid permissions, unsafe tokens, non-root URL markers, encoded path separators, and conflicting URL/`--type` inputs. Runs without hitting the live API.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_AppsPageURL / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, apps `/page/<token>` URL parsing with `file_type=apps`, default `is_solved=false`, default omitted `is_whole` and `user_id_type`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
- TestDrive_CommentOpsDryRun: dry-run coverage for `drive +batch-query-comments`, `drive +resolve-comment`, `drive +restore-comment`, `drive +add-reply`, `drive +list-replies`, `drive +update-reply`, `drive +delete-reply`, and `drive +react-reply`; asserts URL→type inference (incl. Miaoda apps `/page/<token>``file_type=apps` and Base `/base/``file_type=bitable`), `file_type`/`page_size`/`need_reaction`/`need_relation` (docx-gated) query wiring, `comment_ids` / `is_solved` / reply `content.elements[]` (text_run) / reaction `action`+`reaction_type`+`reply_id` body shapes, resolved `:comment_id`/`:reply_id` path segments, the Wiki `get_node -> batch_query` / `get_node -> replies list` / `get_node -> v2 reaction` orchestration plans, and the batch_query wiki dry-run surfacing `need_relation` as the `<sent only when obj_type is docx>` placeholder, without live API calls. All eight verified manually against live documents (list → batch-query → add-reply → list-replies → update-reply → react add/delete → resolve/restore → delete-reply round trip; root-reply update rewriting the comment body, the `1069303 forbidden` cross-identity update rejection, the server persisting arbitrary `reaction_type` strings, and count=0 reaction tombstones were probed live as well).
@@ -42,7 +41,7 @@
| ✓ | drive +react-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--token + --type wiki` resolve plan; v2 `.../comments/reaction` POST; `--reply-id`/`--emoji`/`--action add\|delete` → body `reply_id`/`reaction_type`/`action` | dry-run pins request/body shape and wiki resolve plan; opt-in live workflow adds then removes a reaction, polling `+list-replies --need-reaction` with count>0 presence checks; local `--emoji` enum validation guards the unvalidated server field |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence |
| | drive +download | shortcut | drive_download_dryrun_test.go::TestDriveDownloadDryRun_DefaultNamePlansMetadataBeforeDownload; drive_download_dryrun_test.go::TestDriveDownloadDryRun_ExplicitOutputSkipsMetadata; drive_upload_workflow_test.go::TestDrive_UploadWorkflow | omitted `--output` plans `metas.batch_query` before file download; explicit `--output` skips metadata; live workflow downloads an uploaded file both with explicit output and with default remote-name output | dry-run plus live fixture coverage |
| | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |

View File

@@ -67,6 +67,23 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) {
wantType: "sheet",
wantPerm: "edit",
},
{
// Explicit --type must override URL inference: the /docx/ marker
// would infer type=docx, but the caller asked for type=wiki (e.g.
// to apply against the underlying wiki node rather than its docx
// target). The URL token itself is still used as the path token.
name: "explicit --type overrides URL inference",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/docx/doxcnE2E003",
"--type", "wiki",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E003/members/apply",
wantType: "wiki",
wantPerm: "view",
},
{
name: "bare token with explicit type",
args: []string{
@@ -92,33 +109,6 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) {
wantType: "slides",
wantPerm: "view",
},
{
name: "apps page URL infers apps type",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/page/appMetaE2E/?from=share",
"--perm", "view",
"--remark", "access request",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/appMetaE2E/members/apply",
wantType: "apps",
wantPerm: "view",
wantBody: map[string]string{"remark": "access request"},
},
{
name: "bare token with explicit apps type",
args: []string{
"drive", "+apply-permission",
"--token", "appBareMetaE2E",
"--type", "apps",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/appBareMetaE2E/members/apply",
wantType: "apps",
wantPerm: "edit",
},
}
for _, temp := range tests {
@@ -165,42 +155,6 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) {
}
}
func TestDrive_ApplyPermissionDryRunRejectsUnsafeTargets(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
}{
{"bare traversal token", []string{"--token", "..", "--type", "docx"}},
{"bare dot token", []string{"--token", ".", "--type", "docx"}},
{"nested resource marker", []string{"--token", "https://example.feishu.cn/share/docx/doxNestedE2E"}},
{"encoded path separator", []string{"--token", "https://example.feishu.cn/docx/doxTarget%2Fother"}},
{"conflicting URL type", []string{"--token", "https://example.feishu.cn/docx/doxTypeE2E", "--type", "wiki"}},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
args := append([]string{"drive", "+apply-permission", "--perm", "view", "--dry-run"}, tt.args...)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "user"})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("unsafe target must be rejected\nstdout:\n%s", result.Stdout)
}
if combined := result.Stdout + "\n" + result.Stderr; !strings.Contains(combined, "--token") && !strings.Contains(combined, "--type") {
t.Fatalf("expected target validation error\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
})
}
}
// TestDrive_ApplyPermissionDryRunRejectsFullAccess locks in the client-side
// enum guard: the spec rejects perm=full_access, so the shortcut must refuse
// it before the request ever reaches the server. Exercised end-to-end to

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestDriveDownloadDryRun_DefaultNamePlansMetadataBeforeDownload(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+download",
"--file-token", "fileDryRunDownload",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 2 {
t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("api.0.method=%q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("api.0.url=%q, want metas batch_query\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.request_docs.0.doc_token").String(); got != "fileDryRunDownload" {
t.Fatalf("api.0.body.request_docs.0.doc_token=%q, want file token\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "GET" {
t.Fatalf("api.1.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunDownload/download" {
t.Fatalf("api.1.url=%q, want file download endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.desc").String(); got != "[2] Download file bytes; Content-Disposition filename wins over metadata title when present" {
t.Fatalf("api.1.desc=%q, want metadata-aware step 2\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "output").String(); got != "<Content-Disposition filename | metadata title | token>" {
t.Fatalf("output=%q, want filename priority placeholder\nstdout:\n%s", got, out)
}
}
func TestDriveDownloadDryRun_ExplicitOutputSkipsMetadata(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+download",
"--file-token", "fileDryRunDownload",
"--output", "./artifacts/report.bin",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/fileDryRunDownload/download" {
t.Fatalf("api.0.url=%q, want file download endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.desc").String(); got != "[1] Download file bytes to the explicit output path" {
t.Fatalf("api.0.desc=%q, want explicit-output step 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "output").String(); got != "./artifacts/report.bin" {
t.Fatalf("output=%q, want explicit output\nstdout:\n%s", got, out)
}
}

View File

@@ -84,41 +84,6 @@ func TestDrive_MemberAddDryRun(t *testing.T) {
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "apps page URL infers apps type",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/page/appMetaE2E/?from=share",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/appMetaE2E/members",
wantResourceType: "apps",
wantMemberID: "ou_e2e_user",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "bare token with explicit apps type",
args: []string{
"drive", "+member-add",
"--token", "appBareMetaE2E",
"--type", "apps",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/appBareMetaE2E/members",
wantResourceType: "apps",
wantMemberID: "ou_e2e_user",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "bare token with explicit wiki type defaults perm_type container",
args: []string{
@@ -439,7 +404,7 @@ func TestDrive_MemberAddDryRunRejectsInvalidInputs(t *testing.T) {
"--member-type", "openid",
"--dry-run",
},
wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes, apps",
wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes",
},
{
name: "member-id prefix conflicts with explicit member-type",

View File

@@ -49,27 +49,6 @@ func TestDrive_MemberListDryRun(t *testing.T) {
wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members",
wantType: "folder",
},
{
name: "apps page URL infers apps type",
args: []string{
"drive", "+member-list",
"--token", "https://example.feishu.cn/page/appMetaE2E/?from=share",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/appMetaE2E/members",
wantType: "apps",
},
{
name: "bare token with explicit apps type",
args: []string{
"drive", "+member-list",
"--token", "appBareMetaE2E",
"--type", "apps",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/appBareMetaE2E/members",
wantType: "apps",
},
{
name: "fields star is passed only when explicit",
args: []string{

View File

@@ -57,47 +57,6 @@ func TestDrive_PermissionGetSettingDryRun(t *testing.T) {
wantURL: "/open-apis/drive/v2/permissions/doxE2E001/public",
wantType: "docx",
},
{
name: "apps page URL infers apps type",
args: []string{
"drive", "+permission-get-setting",
"--token", "https://example.feishu.cn/page/appMetaE2E/?from=share",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/appMetaE2E/public",
wantType: "apps",
},
{
name: "minutes URL infers minutes type",
args: []string{
"drive", "+permission-get-setting",
"--token", "https://example.feishu.cn/minutes/obcnE2E001",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/obcnE2E001/public",
wantType: "minutes",
},
{
name: "canonical mindnote URL infers mindnote type",
args: []string{
"drive", "+permission-get-setting",
"--token", "https://example.feishu.cn/mindnote/mndE2E001",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/mndE2E001/public",
wantType: "mindnote",
},
{
name: "bare token with explicit apps type",
args: []string{
"drive", "+permission-get-setting",
"--token", "appBareMetaE2E",
"--type", "apps",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/appBareMetaE2E/public",
wantType: "apps",
},
}
for _, temp := range tests {
@@ -130,31 +89,6 @@ func TestDrive_PermissionGetSettingDryRun(t *testing.T) {
}
}
func TestDrive_PermissionGetSettingDryRunRejectsMultiSegmentToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, args := range [][]string{
{"drive", "+permission-get-setting", "--token", "doxTarget/other", "--type", "docx", "--dry-run"},
{"drive", "+permission-get-setting", "--token", ".", "--type", "docx", "--dry-run"},
{"drive", "+permission-get-setting", "--token", "https://example.feishu.cn/docx/doxTarget%2Fother", "--dry-run"},
{"drive", "+permission-get-setting", "--token", "ftp://example.feishu.cn/docx/doxTarget", "--dry-run"},
} {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
cancel()
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("multi-segment token must be rejected\nstdout:\n%s", result.Stdout)
}
if combined := result.Stdout + "\n" + result.Stderr; !strings.Contains(combined, "--token") {
t.Fatalf("expected token validation error\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
}
func TestDrive_PermissionGetSettingWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -5,7 +5,6 @@ package drive
import (
"context"
"strings"
"testing"
"time"
@@ -96,70 +95,3 @@ func TestDrive_SecureLabelDryRun(t *testing.T) {
})
}
}
func TestDrive_SecureLabelDryRunRejectsInvalidTargets(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
}{
{
name: "query",
args: []string{"--token", "https://example.feishu.cn/share?redirect=/docx/doxQueryE2E"},
},
{
name: "fragment",
args: []string{"--token", "https://example.feishu.cn/share#/docx/doxFragmentE2E"},
},
{
name: "empty host",
args: []string{"--token", "https:///docx/doxNoHostE2E"},
},
{
name: "nested resource marker",
args: []string{"--token", "https://example.feishu.cn/share/docx/doxNestedE2E"},
},
{
name: "encoded path separator",
args: []string{"--token", "https://example.feishu.cn/docx/doxTarget%2Fother"},
},
{
name: "bare traversal token",
args: []string{"--token", "..", "--type", "docx"},
},
{
name: "bare dot token",
args: []string{"--token", ".", "--type", "docx"},
},
{
name: "conflicting URL type",
args: []string{"--token", "https://example.feishu.cn/docx/doxTypeE2E", "--type", "wiki"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
args := append([]string{"drive", "+secure-label-update"}, tt.args...)
args = append(args, "--label-id", "7217780879644737539", "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("invalid target must be rejected\nstdout:\n%s", result.Stdout)
}
if combined := result.Stdout + "\n" + result.Stderr; !strings.Contains(combined, "--token") && !strings.Contains(combined, "--type") {
t.Fatalf("expected target validation error\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
})
}
}

View File

@@ -117,33 +117,4 @@ func TestDrive_UploadWorkflow(t *testing.T) {
if string(data) != updatedContent {
t.Fatalf("downloaded content=%q want %q", string(data), updatedContent)
}
defaultDownloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+download",
"--file-token", overwriteToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
defaultDownloadResult.AssertExitCode(t, 0)
defaultDownloadResult.AssertStdoutStatus(t, true)
defaultSavedPath := gjson.Get(defaultDownloadResult.Stdout, "data.saved_path").String()
require.NotEmpty(t, defaultSavedPath, "download without --output should return saved_path")
if got := filepath.Base(defaultSavedPath); got != "overwrite.txt" {
t.Fatalf("default saved basename=%q want overwrite.txt\nstdout:\n%s", got, defaultDownloadResult.Stdout)
}
t.Cleanup(func() {
_ = os.Remove(defaultSavedPath)
})
data, err = os.ReadFile(defaultSavedPath)
if err != nil {
t.Fatalf("read default downloaded file: %v", err)
}
if string(data) != updatedContent {
t.Fatalf("default downloaded content=%q want %q", string(data), updatedContent)
}
}