mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
2 Commits
docs/wiki-
...
fix/base-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d488e379 | ||
|
|
236bf7d253 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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/clear,body {env}。清空当前应用指定环境下全部缓存,用于无法定位
|
||||
// 具体 key 的快速恢复;影响面大,定 high-risk-write(框架自动注入 --yes 确认)。
|
||||
var AppsCacheClear = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-clear",
|
||||
Description: "Clear all cache entries for the app in the given environment",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appCacheClearPath(appID)).
|
||||
Desc("Clear all cache entries for the app in the given environment").
|
||||
Body(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheClearPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
|
||||
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
|
||||
n := int64(0)
|
||||
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
|
||||
n = int64(f)
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)时不带 value,ttl_ms/value_size_bytes 为 null。
|
||||
var AppsCacheGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-get",
|
||||
Description: "Get a business cache key's value and metadata",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appCachePath(appID)).
|
||||
Desc("Get a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectCacheGet(data, key, rctx)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectCacheGet 组装 cache-get 输出:key 回显、environment 取 resolved env、exists 直读;
|
||||
// 命中时带 ttl_ms + value(原始串)+ value_size_bytes(CLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
|
||||
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
|
||||
exists := cacheBool(data["exists"])
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"exists": exists,
|
||||
}
|
||||
if exists {
|
||||
val := common.GetString(data, "value")
|
||||
out["ttl_ms"] = cacheInt(data["ttl_ms"])
|
||||
out["value_size_bytes"] = len([]byte(val))
|
||||
out["value"] = val
|
||||
} else {
|
||||
out["ttl_ms"] = nil
|
||||
out["value_size_bytes"] = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderCacheGetPretty 打元信息块(key/environment/exists,命中再加 ttl/value_size),命中时末尾展开 value。
|
||||
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
exists, _ := out["exists"].(bool)
|
||||
pairs := [][2]string{
|
||||
{"key", common.GetString(out, "key")},
|
||||
{"environment", common.GetString(out, "environment")},
|
||||
{"exists", fmt.Sprintf("%v", exists)},
|
||||
}
|
||||
if exists {
|
||||
pairs = append(pairs,
|
||||
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
|
||||
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
|
||||
)
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
if exists {
|
||||
fmt.Fprintln(w, "value:")
|
||||
printCacheValuePretty(w, common.GetString(out, "value"))
|
||||
}
|
||||
}
|
||||
@@ -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_HitPretty:pretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
|
||||
func TestAppsCacheGet_HitPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_Miss:未命中——exists=false,无 value,ttl_ms / value_size_bytes 为 null。
|
||||
func TestAppsCacheGet_Miss(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": false,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != false {
|
||||
t.Fatalf("miss exists=%v", d["exists"])
|
||||
}
|
||||
if _, ok := d["value"]; ok {
|
||||
t.Fatalf("miss must not carry value: %v", d)
|
||||
}
|
||||
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
|
||||
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_ExistsAsString:服务端把 exists 返成字符串 "true" 时仍按命中处理
|
||||
// (cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
|
||||
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != true {
|
||||
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("命中应带 value, got %v", d["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_PrettyNonJSONFallback:pretty 下 value 不是合法 JSON 时降级原样输出
|
||||
// (safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
|
||||
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
|
||||
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_TTLAsStringNormalized:服务端把 ttl_ms 返成字符串 "272000" 时,
|
||||
// 输出的 ttl_ms 必须归一成 JSON number(cacheInt),不得随 wire 形态漂移成字符串。
|
||||
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
f, ok := d["ttl_ms"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
if int(f) != 272000 {
|
||||
t.Fatalf("ttl_ms = %v, want 272000", f)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_CountAsStringNormalized:服务端把 deleted_key_count 返成字符串 "1" 时,
|
||||
// 输出必须归一成 JSON number(cacheInt)。
|
||||
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if _, ok := d["deleted_key_count"].(float64); !ok {
|
||||
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunOmitsEnv:不传 --environment 时 dry-run query 不带 env(服务端自动选),但带 key。
|
||||
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "GET" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
|
||||
}
|
||||
if a.Params["key"] != "k:1" {
|
||||
t.Fatalf("key must be in query, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunWithEnv:显式 --environment dev → query 带 env=dev。
|
||||
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Params["env"] != "dev" {
|
||||
t.Fatalf("env must be dev, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_RequiresKey:缺 --key → 校验错。
|
||||
func TestAppsCacheGet_RequiresKey(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --key error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-delete ──
|
||||
|
||||
// TestAppsCacheDelete_Hit:删中命中的 key → deleted_key_count=1;pretty 打 "✓ cache deleted"。
|
||||
func TestAppsCacheDelete_Hit(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache deleted") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentJSON:目标不存在 → 幂等成功,deleted_key_count=0,pretty 措辞区分。
|
||||
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
|
||||
t.Fatalf("absent data=%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentPretty:不存在 pretty 打 "✓ cache already absent"。
|
||||
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "already absent") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_DryRun:DELETE 方法、/cache 路由,query 带 key + env。
|
||||
func TestAppsCacheDelete_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "DELETE" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
|
||||
t.Fatalf("params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-clear ──
|
||||
|
||||
// TestAppsCacheClear_Success:清空成功 → deleted_key_count=128;pretty 打 "✓ cache cleared: 128 entries (dev)"。
|
||||
func TestAppsCacheClear_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: cacheClearURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_RequiresConfirmation:high-risk-write 无 --yes → 被确认门拦截。
|
||||
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyWithEnv:dry-run POST /cache/clear,body 带 env=dev。
|
||||
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "POST" || a.URL != cacheClearURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["env"] != "dev" {
|
||||
t.Fatalf("body must carry env=dev, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyOmitsEnv:不传 --environment → body 不带 env(服务端自动选)。
|
||||
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if _, ok := a.Body["env"]; ok {
|
||||
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项(method/url/params/body)。
|
||||
// 复用本包规范的 dryRunAPIEnvelope(api 现嵌在 data.api 下,见 dryrun_test.go)。
|
||||
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
|
||||
t.Helper()
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
|
||||
t.Fatalf("bad dry-run json: %v\n%s", err, s)
|
||||
}
|
||||
return env.API[0]
|
||||
}
|
||||
@@ -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 家族的旧名 --env),env 值经 dbEnv 读、
|
||||
// 经 dbEnvParams 注入——get/delete 放 query,clear 放 body(省略即服务端自动选分支)。
|
||||
|
||||
// appCachePath 返回缓存单 key 读/删 URL:cache(GET 读、DELETE 删,靠方法区分)。
|
||||
func appCachePath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appCacheClearPath 返回清空指定环境缓存 URL:cache/clear。
|
||||
func appCacheClearPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env,
|
||||
// 故只注册干净的 --environment(不带 db 家族那套隐藏 --env + 拒收逻辑)。
|
||||
// 省略即服务端按应用多环境状态自动选分支(多环境→dev,非多环境→online)。
|
||||
func cacheEnvFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "environment",
|
||||
Enum: []string{"dev", "online"},
|
||||
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
|
||||
}
|
||||
}
|
||||
|
||||
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool,
|
||||
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中(hit→miss 翻转)。
|
||||
func cacheBool(v interface{}) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(x), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cacheInt 把服务端下发的数值字段归一成 int64(无法解析→nil)。本仓惯例:数值可能以字符串下发
|
||||
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
|
||||
// (number ↔ string)。归一后输出类型恒定为数字或 null,消费方无需自己容忍字符串。
|
||||
func cacheInt(raw interface{}) interface{} {
|
||||
if f, ok := numericAsFloat(raw); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedEnv 取服务端回吐的 resolved env;缺失时兜底成请求侧 --environment(可能为空)。
|
||||
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
|
||||
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
|
||||
if env := common.GetString(data, "env"); env != "" {
|
||||
return env
|
||||
}
|
||||
return dbEnv(rctx)
|
||||
}
|
||||
|
||||
// formatCacheTTL 把剩余 TTL(毫秒)格式化成 4m32s 这样的时长串;非数字返回 "—"。
|
||||
func formatCacheTTL(ms interface{}) string {
|
||||
f, ok := numericAsFloat(ms)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
return (time.Duration(int64(f)) * time.Millisecond).String()
|
||||
}
|
||||
|
||||
// printCacheValuePretty 把 value 反序列化后缩进展开(pretty 口径);非 JSON 则原样打印。
|
||||
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
|
||||
func printCacheValuePretty(w io.Writer, raw string) {
|
||||
v := safeParseJSON(raw)
|
||||
if s, ok := v.(string); ok {
|
||||
fmt.Fprintln(w, s)
|
||||
return
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, raw)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -64,9 +64,6 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsFileUpload,
|
||||
AppsFileDelete,
|
||||
AppsFileQuotaGet,
|
||||
AppsCacheGet,
|
||||
AppsCacheDelete,
|
||||
AppsCacheClear,
|
||||
AppsGitCredentialInit,
|
||||
AppsGitCredentialList,
|
||||
AppsGitCredentialRemove,
|
||||
|
||||
@@ -20,14 +20,13 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 cache(get/delete/clear)
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。
|
||||
func TestAppsShortcuts_Returns82(t *testing.T) {
|
||||
// - 9 role(role 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) |
|
||||
| 逐条执行 SQL(SELECT / DML / DDL);建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
|
||||
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
|
||||
| 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) |
|
||||
| **部署/上线应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push,再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 创意模式(html)应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |
|
||||
|
||||
@@ -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 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。
|
||||
@@ -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 链接特殊处理
|
||||
|
||||
@@ -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` 调本命令。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 禁止传此参数。 |
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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.*` 时才需要自己做这一步。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. 只有知识库名称
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user