From b5a381ab40bf1ab29523f9f44e3061d6a9dc9902 Mon Sep 17 00:00:00 2001 From: / Date: Tue, 21 Apr 2026 17:55:27 +0800 Subject: [PATCH 1/4] feat(drive): add +apply-permission to request doc access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the POST /drive/v1/permissions/:token/members/apply endpoint as a user-only shortcut. --token accepts either a bare token or a document URL, with type auto-inferred from the URL path (/docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /minutes/, /slides/); an explicit --type always wins. --perm is limited to view or edit; full_access is rejected client-side to match the spec. Classifier gains two domain-specific hints for the endpoint's newly documented error codes: 1063006 (per-user-per-document quota of 5/day reached) and 1063007 (document does not accept apply requests — covers disallow-external-apply, already-has-access, and unsupported-type). --- internal/output/lark_errors.go | 16 ++ internal/output/lark_errors_test.go | 14 + shortcuts/drive/drive_apply_permission.go | 151 +++++++++++ .../drive/drive_apply_permission_test.go | 239 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 1 + skills/lark-drive/SKILL.md | 1 + .../references/lark-drive-apply-permission.md | 77 ++++++ 8 files changed, 500 insertions(+) create mode 100644 shortcuts/drive/drive_apply_permission.go create mode 100644 shortcuts/drive/drive_apply_permission_test.go create mode 100644 skills/lark-drive/references/lark-drive-apply-permission.md diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index e58d306f8..269d952fc 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -41,6 +41,14 @@ const ( // Sheets float image: width/height/offset out of range or invalid. LarkErrSheetsFloatImageInvalidDims = 1310246 + + // Drive permission apply: per-user-per-document submission limit (5/day) reached. + LarkErrDrivePermApplyRateLimit = 1063006 + // Drive permission apply: request is not applicable for this document + // (e.g. the document is configured to disallow access requests, or the + // caller already holds the requested permission, or the target type does + // not accept apply operations). + LarkErrDrivePermApplyNotApplicable = 1063007 ) // ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint). @@ -82,6 +90,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) { return ExitAPI, "invalid_params", "check --width / --height / --offset-x / --offset-y: " + "width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height" + + // drive permission-apply specific guidance + case LarkErrDrivePermApplyRateLimit: + return ExitAPI, "rate_limit", + "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly" + case LarkErrDrivePermApplyNotApplicable: + return ExitAPI, "invalid_params", + "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly" } return ExitAPI, "api_error", "" diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index 8b2fa267d..63c82002d 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -47,6 +47,20 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) { wantType: "invalid_params", wantHint: "--width / --height / --offset-x / --offset-y", }, + { + name: "drive permission apply rate limit", + code: LarkErrDrivePermApplyRateLimit, + wantExitCode: ExitAPI, + wantType: "rate_limit", + wantHint: "5 times per day", + }, + { + name: "drive permission apply not applicable", + code: LarkErrDrivePermApplyNotApplicable, + wantExitCode: ExitAPI, + wantType: "invalid_params", + wantHint: "does not accept a permission-apply request", + }, } for _, tt := range tests { diff --git a/shortcuts/drive/drive_apply_permission.go b/shortcuts/drive/drive_apply_permission.go new file mode 100644 index 000000000..5841baec0 --- /dev/null +++ b/shortcuts/drive/drive_apply_permission.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// permApplyTypes is the authoritative list of type values the apply-permission +// endpoint accepts for its required `type` query parameter. +var permApplyTypes = []string{ + "doc", "sheet", "file", "wiki", "bitable", "docx", + "mindnote", "minutes", "slides", +} + +// 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"}, + {"/minutes/", "minutes"}, + {"/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. Explicit --type wins over URL inference. +func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", output.ErrValidation("--token is required") + } + + if strings.Contains(raw, "://") { + for _, m := range permApplyURLMarkers { + if tok, ok := extractURLToken(raw, m.Marker); ok { + token = tok + if explicitType == "" { + docType = m.Type + } + break + } + } + if token == "" { + return "", "", output.ErrValidation( + "could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /minutes/, /slides/. Pass a bare token with --type instead if the URL shape is unusual", + raw, + ) + } + } else { + token = raw + } + + if explicitType != "" { + docType = explicitType + } + if docType == "" { + return "", "", output.ErrValidation( + "--type is required when --token is a bare token; accepted values: %s", + strings.Join(permApplyTypes, ", "), + ) + } + return token, docType, 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. +// +// The backend accepts only user_access_token for this endpoint, so the +// shortcut declares AuthTypes: ["user"] — bot identity is rejected up-front. +var DriveApplyPermission = common.Shortcut{ + Service: "drive", + Command: "+apply-permission", + Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides", + Risk: "write", + Scopes: []string{"docs:permission.member:apply"}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/minutes/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"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + body := buildPermApplyBody(runtime) + return common.NewDryRunAPI(). + Desc("Apply to document owner for access"). + POST("/open-apis/drive/v1/permissions/:token/members/apply"). + Params(map[string]interface{}{"type": docType}). + Body(body). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return err + } + body := buildPermApplyBody(runtime) + + fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n", + runtime.Str("perm"), docType, common.MaskToken(token)) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)), + map[string]interface{}{"type": docType}, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// buildPermApplyBody returns the request body with the caller-supplied perm +// and optional remark. remark is omitted entirely when empty so the server +// doesn't render an empty note on the request card. +func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{"perm": runtime.Str("perm")} + if s := runtime.Str("remark"); s != "" { + body["remark"] = s + } + return body +} diff --git a/shortcuts/drive/drive_apply_permission_test.go b/shortcuts/drive/drive_apply_permission_test.go new file mode 100644 index 000000000..29071738e --- /dev/null +++ b/shortcuts/drive/drive_apply_permission_test.go @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── resolvePermApplyTarget unit tests ──────────────────────────────────────── + +func TestResolvePermApplyTarget_BareTokenNeedsType(t *testing.T) { + t.Parallel() + _, _, err := resolvePermApplyTarget("bareToken", "") + if err == nil || !strings.Contains(err.Error(), "--type is required") { + t.Fatalf("expected --type required error, got: %v", err) + } +} + +func TestResolvePermApplyTarget_BareTokenWithType(t *testing.T) { + t.Parallel() + token, docType, err := resolvePermApplyTarget("bareToken", "docx") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "bareToken" || docType != "docx" { + t.Fatalf("got token=%q type=%q, want bareToken/docx", token, docType) + } +} + +func TestResolvePermApplyTarget_URLInference(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw string + wantTok string + wantType string + }{ + {"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"}, + {"sheets", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"}, + {"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"}, + {"bitable", "https://example.feishu.cn/bitable/bscTok789", "bscTok789", "bitable"}, + {"file", "https://example.feishu.cn/file/boxTok111", "boxTok111", "file"}, + {"wiki", "https://example.feishu.cn/wiki/wikTok222", "wikTok222", "wiki"}, + {"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"}, + {"mindnote", "https://example.feishu.cn/mindnote/mnTok444", "mnTok444", "mindnote"}, + {"minutes", "https://example.feishu.cn/minutes/minTok555", "minTok555", "minutes"}, + {"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"}, + } + 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("unexpected error: %v", err) + } + if token != tt.wantTok || docType != tt.wantType { + t.Fatalf("got (%q,%q), want (%q,%q)", token, docType, tt.wantTok, tt.wantType) + } + }) + } +} + +func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) { + t.Parallel() + // Even though the URL marker is /docx/, an explicit --type wins. + token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "doxTok123" || docType != "wiki" { + t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType) + } +} + +func TestResolvePermApplyTarget_UnrecognizedURL(t *testing.T) { + t.Parallel() + _, _, err := resolvePermApplyTarget("https://example.feishu.cn/unknown/xyz", "") + if err == nil || !strings.Contains(err.Error(), "could not infer token") { + t.Fatalf("expected infer error, got: %v", err) + } +} + +func TestResolvePermApplyTarget_Empty(t *testing.T) { + t.Parallel() + _, _, err := resolvePermApplyTarget(" ", "docx") + if err == nil || !strings.Contains(err.Error(), "--token is required") { + t.Fatalf("expected token required error, got: %v", err) + } +} + +// ── shortcut integration tests ────────────────────────────────────────────── + +func TestDriveApplyPermission_ValidateMissingToken(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveApplyPermission, []string{ + "+apply-permission", "--perm", "view", "--type", "docx", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestDriveApplyPermission_ValidateRejectsBadPerm(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveApplyPermission, []string{ + "+apply-permission", + "--token", "doxTok", + "--type", "docx", + "--perm", "full_access", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--perm") { + t.Fatalf("expected perm enum error, got: %v", err) + } +} + +func TestDriveApplyPermission_DryRunInfersTypeFromURL(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveApplyPermission, []string{ + "+apply-permission", + "--token", "https://example.feishu.cn/sheets/shtTok?sheet=abc", + "--perm", "edit", + "--remark", "please", + "--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/shtTok/members/apply", + `"POST"`, + `"sheet"`, + `"edit"`, + `"please"`, + `"shtTok"`, + } { + 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 + // URL contains that query, so this doubles as an assertion that the + // shortcut emits the type query parameter. + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxTok123/members/apply?type=docx", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"applied": true}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveApplyPermission, []string{ + "+apply-permission", + "--token", "doxTok123", + "--type", "docx", + "--perm", "view", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["perm"] != "view" { + t.Fatalf("perm = %v, want view", body["perm"]) + } + if _, hasRemark := body["remark"]; hasRemark { + t.Fatalf("remark should be omitted when empty, got: %v", body["remark"]) + } +} + +func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxTok/members/apply", + Status: 400, + Body: map[string]interface{}{ + "code": 1063007, "msg": "request not applicable", + }, + }) + + err := mountAndRunDrive(t, DriveApplyPermission, []string{ + "+apply-permission", + "--token", "doxTok", + "--type", "docx", + "--perm", "view", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for 1063007") + } + if !strings.Contains(err.Error(), "not applicable") { + t.Fatalf("expected surfaced server message, got: %v", err) + } +} + +func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxTok/members/apply", + Status: 429, + Body: map[string]interface{}{ + "code": 1063006, "msg": "quota exceeded", + }, + }) + + err := mountAndRunDrive(t, DriveApplyPermission, []string{ + "+apply-permission", + "--token", "doxTok", + "--type", "docx", + "--perm", "view", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for 1063006") + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 85d4821d4..67ba703b2 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut { DriveMove, DriveDelete, DriveTaskResult, + DriveApplyPermission, } } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index e8699735b..be0857aa3 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -22,6 +22,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+move", "+delete", "+task_result", + "+apply-permission", } if len(got) != len(want) { diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 712031eca..fccd1a2ac 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -207,6 +207,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive | | [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes | | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | +| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) | ## API Resources diff --git a/skills/lark-drive/references/lark-drive-apply-permission.md b/skills/lark-drive/references/lark-drive-apply-permission.md new file mode 100644 index 000000000..30d9e2245 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-apply-permission.md @@ -0,0 +1,77 @@ + +# drive +apply-permission(申请文档权限) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli drive +apply-permission`。 + +向云文档 **Owner** 发起 `view` 或 `edit` 权限申请。申请会以卡片形式推送给 Owner,由 Owner 决定是否通过。 + +> [!CAUTION] +> 这是**写入操作** —— 会给 Owner 发推送通知,不要批量或自动化调用。可以先用 `--dry-run` 预览。 + +## 身份要求 + +- **仅支持 `user` 身份**(使用 `user_access_token`),不支持 `bot` / `tenant_access_token`;shortcut 已在 `AuthTypes` 中强制限定为 `user`,使用 bot 会被拒。 +- 所需 scope:`docs:permission.member:apply`(若用户缺权限会走统一的 permission 错误路径)。 + +## 命令 + +```bash +# 通过 URL 申请(type 自动从 URL 推断) +lark-cli drive +apply-permission \ + --token "https://example.larksuite.com/docx/doxcnxxxxxxxxx" \ + --perm view \ + --remark "安全评估:需查看需求文档内容" --as user + +# 通过 bare token + 显式 --type +lark-cli drive +apply-permission \ + --token "doxcnxxxxxxxxx" --type docx \ + --perm edit --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token` | 是 | 目标文档 token 或完整 URL(`/docx/`、`/sheets/`、`/base/`、`/bitable/`、`/file/`、`/wiki/`、`/doc/`、`/mindnote/`、`/minutes/`、`/slides/` 路径里的 token 会被自动提取) | +| `--type` | 否 | 目标类型,可选值 `doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides`。传 URL 时可由 shortcut 自动推断;bare token 必须显式传 | +| `--perm` | 是 | 申请的权限,仅支持 `view` 或 `edit`(**不支持 `full_access`**,CLI 侧会直接拒绝) | +| `--remark` | 否 | 备注,会显示在权限申请卡片上 | +| `--dry-run` | 否 | 仅打印请求内容,不实际发送 | + +## 输出 + +API 成功时返回空 `data`(仅 `code: 0, msg: "success"`),对应 CLI 输出: + +```json +{ + "ok": true, + "identity": "user", + "data": {} +} +``` + +## 频率限制 + +- **应用级**:每应用每租户每分钟最多 10 次。 +- **用户级**:同一用户对**同一篇文档**一天不超过 5 次。 + +## 常见错误 + +| 错误码 | 含义 | CLI 处理 | +|---|---|---| +| `1063006` | 申请次数已达上限(5 次/日) | CLI 自动加 hint:`permission-apply quota reached: each user may request access on the same document at most 5 times per day` | +| `1063007` | 当前文档无法申请(如:文档禁用外部申请、申请者已拥有对应权限、目标类型不支持 apply) | CLI 自动加 hint:`this document does not accept a permission-apply request ... contact the owner directly` | +| `1063002` | 无操作权限(如该租户关闭了外部申请) | 由统一 permission 错误路径处理 | +| `1063004` | 用户所在组织无分享权限 | 由统一 permission 错误路径处理 | +| `1063005` | 资源已删除 | 需要确认目标文档/节点是否仍存在 | +| `1066001/1066002` | 服务端异常 / 并发冲突 | 稍后重试 | + +## 与 wiki URL 的关系 + +传入 `/wiki/` 时,shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),自行先调 `wiki spaces get_node` 拿 `obj_token + obj_type`,再用 bare token + `--type docx` 调本命令。 + +## 参考 + +- OpenAPI 端点:`POST /open-apis/drive/v1/permissions/:token/members/apply` From 3cc22c585f96f295f6cc80f6b73ccd67b178282f Mon Sep 17 00:00:00 2001 From: / Date: Tue, 21 Apr 2026 18:21:29 +0800 Subject: [PATCH 2/4] test(drive): add dry-run E2E for +apply-permission Invoke the real CLI binary via clie2e.RunCmd under --dry-run and parse the rendered request JSON with gjson to lock in method, URL path (including the token segment), type query parameter (auto-inferred for docx / sheet / slides URLs, taken from explicit --type for bare tokens), perm body field, and remark presence/omission. A separate test asserts --perm full_access is rejected by the enum validator before reaching the server. Fake LARKSUITE_CLI_APP_ID / APP_SECRET / BRAND are enough because dry-run short-circuits before any API call. Update drive coverage.md to add a row and refresh metrics. --- tests/cli_e2e/drive/coverage.md | 10 +- .../drive_apply_permission_dryrun_test.go | 168 ++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 4dc2d8c3b..fdb2cee8d 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -1,20 +1,22 @@ # Drive CLI E2E Coverage ## Metrics -- Denominator: 28 leaf commands -- Covered: 1 -- Coverage: 3.6% +- Denominator: 29 leaf commands +- Covered: 2 +- Coverage: 6.9% ## 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_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference for bare-token inputs, 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. - Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered. -- Blocked area: upload, export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. +- Blocked area: upload, export, comment, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. Permission flows are partially covered via the dry-run test for `+apply-permission`; the full permission.members.* API surface remains uncovered. ## Command Table | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | | ✕ | drive +add-comment | shortcut | | none | no comment workflow yet | +| ✓ | 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 | | none | no primary delete workflow yet | | ✕ | drive +download | shortcut | | none | no file fixture workflow yet | | ✕ | drive +export | shortcut | | none | no export workflow yet | diff --git a/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go b/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go new file mode 100644 index 000000000..c0d6427a2 --- /dev/null +++ b/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_ApplyPermissionDryRun locks in the request shape the shortcut +// emits under --dry-run: the real CLI binary is invoked end-to-end (so the +// full flag-parsing, validation, and dry-run renderers all execute), and the +// printed request is inspected to confirm +// - HTTP method, URL template, and the token path segment, +// - type query parameter (auto-inferred from a URL input, explicit for a +// bare token input), +// - perm / remark body fields. +// +// Fake credentials are sufficient because --dry-run short-circuits before +// any network call. +func TestDrive_ApplyPermissionDryRun(t *testing.T) { + 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 + wantURL string + wantType string + wantPerm string + wantBody map[string]string // optional substrings (key=rendered token) to require + }{ + { + name: "URL input auto-infers docx type", + args: []string{ + "drive", "+apply-permission", + "--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share", + "--perm", "view", + "--remark", "e2e note", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members/apply", + wantType: "docx", + wantPerm: "view", + wantBody: map[string]string{"remark": "e2e note"}, + }, + { + name: "URL input auto-infers sheet type", + args: []string{ + "drive", "+apply-permission", + "--token", "https://example.feishu.cn/sheets/shtcnE2E002?sheet=abc", + "--perm", "edit", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/shtcnE2E002/members/apply", + wantType: "sheet", + wantPerm: "edit", + }, + { + name: "bare token with explicit type wins over inference", + args: []string{ + "drive", "+apply-permission", + "--token", "bscE2E003", + "--type", "bitable", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/bscE2E003/members/apply", + wantType: "bitable", + wantPerm: "view", + }, + { + name: "slides URL inference", + args: []string{ + "drive", "+apply-permission", + "--token", "https://example.feishu.cn/slides/slE2E004", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/slE2E004/members/apply", + wantType: "slides", + wantPerm: "view", + }, + } + + 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) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + // Dry-run output is the JSON envelope; gjson walks into api[0]. + if got := gjson.Get(out, "api.0.method").String(); got != "POST" { + t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType { + t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out) + } + if got := gjson.Get(out, "api.0.body.perm").String(); got != tt.wantPerm { + t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out) + } + for k, v := range tt.wantBody { + if got := gjson.Get(out, "api.0.body."+k).String(); got != v { + t.Fatalf("body.%s = %q, want %q\nstdout:\n%s", k, got, v, out) + } + } + // When no --remark is passed, the body must NOT carry an empty + // remark field (the owner's request card would otherwise render + // a blank note). + if _, wantsRemark := tt.wantBody["remark"]; !wantsRemark { + if gjson.Get(out, "api.0.body.remark").Exists() { + t.Fatalf("body.remark should be omitted when --remark is empty, stdout:\n%s", out) + } + } + }) + } +} + +// 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 +// guarantee the enum validator is wired into the mount path. +func TestDrive_ApplyPermissionDryRunRejectsFullAccess(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+apply-permission", + "--token", "doxcnE2E999", + "--type", "docx", + "--perm", "full_access", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + if result.ExitCode == 0 { + t.Fatalf("full_access must be rejected, got exit=0\nstdout:\n%s", result.Stdout) + } + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "perm") { + t.Fatalf("expected perm-related error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } +} From d0c876e36c0e3dfb09359b18edb44db5c780c0cb Mon Sep 17 00:00:00 2001 From: / Date: Tue, 21 Apr 2026 20:42:52 +0800 Subject: [PATCH 3/4] test(drive): isolate E2E dry-run subprocess from local CLI config Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in both +apply-permission dry-run tests so the subprocess can't read a developer's real credentials/profile instead of the fake env vars the tests inject. --- tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go b/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go index c0d6427a2..91d658f6d 100644 --- a/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go @@ -26,6 +26,10 @@ import ( // Fake credentials are sufficient because --dry-run short-circuits before // any network call. func TestDrive_ApplyPermissionDryRun(t *testing.T) { + // Isolate from any local CLI state: the subprocess inherits the parent + // test environment, and without an explicit config dir it could read a + // developer's real credentials/profile instead of the fake ones below. + 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") @@ -140,6 +144,10 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) { // it before the request ever reaches the server. Exercised end-to-end to // guarantee the enum validator is wired into the mount path. func TestDrive_ApplyPermissionDryRunRejectsFullAccess(t *testing.T) { + // Isolate from any local CLI state: the subprocess inherits the parent + // test environment, and without an explicit config dir it could read a + // developer's real credentials/profile instead of the fake ones below. + 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") From ff735712b208265cba0bfafd82c7a31416e1887d Mon Sep 17 00:00:00 2001 From: / Date: Tue, 21 Apr 2026 20:44:41 +0800 Subject: [PATCH 4/4] test(drive): add E2E case that exercises URL inference override Previous "bare token with explicit type wins over inference" row used a bare token, which has no URL-derived type to override. Replace it with a /docx/ URL + --type wiki combo that actually forces the explicit flag to win over URL inference, and add a separate bare-token row to keep the simpler path covered. Refresh coverage.md wording to match. --- tests/cli_e2e/drive/coverage.md | 2 +- .../drive_apply_permission_dryrun_test.go | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index fdb2cee8d..6d5571bfc 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -7,7 +7,7 @@ ## 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_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference for bare-token inputs, 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. +- 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. - Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered. - Blocked area: upload, export, comment, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. Permission flows are partially covered via the dry-run test for `+apply-permission`; the full permission.members.* API surface remains uncovered. diff --git a/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go b/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go index 91d658f6d..9d9e17eab 100644 --- a/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go @@ -69,15 +69,32 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) { wantPerm: "edit", }, { - name: "bare token with explicit type wins over inference", + // 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", "bscE2E003", + "--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{ + "drive", "+apply-permission", + "--token", "bscE2E004", "--type", "bitable", "--perm", "view", "--dry-run", }, - wantURL: "/open-apis/drive/v1/permissions/bscE2E003/members/apply", + wantURL: "/open-apis/drive/v1/permissions/bscE2E004/members/apply", wantType: "bitable", wantPerm: "view", },