diff --git a/shortcuts/drive/drive_secure_label.go b/shortcuts/drive/drive_secure_label.go new file mode 100644 index 000000000..3d2dee1a6 --- /dev/null +++ b/shortcuts/drive/drive_secure_label.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + secureLabelReadScope = "drive:file.meta.sec_label.read_only" + secureLabelUpdateScope = "docs:secure_label:write_only" +) + +var secureLabelTypes = permApplyTypes + +// DriveSecureLabelList lists secure labels available to the current user. +var DriveSecureLabelList = common.Shortcut{ + Service: "drive", + Command: "+secure-label-list", + Description: "List secure labels available to the current user", + Risk: "read", + Scopes: []string{secureLabelReadScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"}, + {Name: "page-token", Desc: "pagination token from previous response"}, + {Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + pageSize := runtime.Int("page-size") + if pageSize < 1 || pageSize > 10 { + return output.ErrValidation("--page-size must be between 1 and 10") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List secure labels available to the current user"). + GET("/open-apis/drive/v2/my_secure_labels"). + Params(buildSecureLabelListParams(runtime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := runtime.CallAPI("GET", + "/open-apis/drive/v2/my_secure_labels", + buildSecureLabelListParams(runtime), + nil, + ) + if err != nil { + return err + } + runtime.OutFormat(data, nil, nil) + return nil + }, +} + +// DriveSecureLabelUpdate updates the secure label on a Drive file/document. +var DriveSecureLabelUpdate = common.Shortcut{ + Service: "drive", + Command: "+secure-label-update", + Description: "Update the secure label on a Drive file or document", + Risk: "write", + Scopes: []string{secureLabelUpdateScope}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "token", Desc: "target file 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: secureLabelTypes}, + {Name: "label-id", Desc: "secure label ID to set", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("Update Drive secure label"). + PATCH("/open-apis/drive/v2/files/:file_token/secure_label"). + Params(map[string]interface{}{"type": docType}). + Body(map[string]interface{}{"id": runtime.Str("label-id")}). + Set("file_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return err + } + body := map[string]interface{}{"id": runtime.Str("label-id")} + data, err := runtime.CallAPI("PATCH", + fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)), + map[string]interface{}{"type": docType}, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + if pageToken := runtime.Str("page-token"); pageToken != "" { + params["page_token"] = pageToken + } + if lang := runtime.Str("lang"); lang != "" { + params["lang"] = lang + } + return params +} + +func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) { + return resolvePermApplyTarget(raw, explicitType) +} diff --git a/shortcuts/drive/drive_secure_label_test.go b/shortcuts/drive/drive_secure_label_test.go new file mode 100644 index 000000000..6132c4357 --- /dev/null +++ b/shortcuts/drive/drive_secure_label_test.go @@ -0,0 +1,164 @@ +// 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" +) + +func TestDriveSecureLabelList_DryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--page-size", "5", + "--page-token", "page_1", + "--lang", "zh", + "--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/v2/my_secure_labels", + `"GET"`, + `"page_size": 5`, + `"page_token": "page_1"`, + `"lang": "zh"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--page-size", "11", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "page-size") { + t.Fatalf("expected page-size validation error, got: %v", err) + } +} + +func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/my_secure_labels?page_size=10", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "7217780879644737540", "name": "L1"}, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"L1"`) { + t.Fatalf("stdout missing label:\n%s", stdout.String()) + } +} + +func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "https://example.feishu.cn/docx/doxTok123?from=share", + "--label-id", "7217780879644737539", + "--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/v2/files/doxTok123/secure_label", + `"PATCH"`, + `"docx"`, + `"id": "7217780879644737539"`, + `"file_token": "doxTok123"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "doxTok123", + "--type", "docx", + "--label-id", "7217780879644737539", + "--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["id"] != "7217780879644737539" { + t.Fatalf("id = %v, want label id", body["id"]) + } +} + +func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label", + Status: 403, + Body: map[string]interface{}{ + "code": 1063013, "msg": "Security label downgrade requires approval", + }, + }) + + targetURL := "https://example.feishu.cn/docx/doxTok123" + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", targetURL, + "--label-id", "7217780879644737539", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected 1063013 error") + } + if !strings.Contains(err.Error(), "Security label downgrade requires approval") { + t.Fatalf("expected raw API error message, got: %v", err) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index dcf231e7c..91df7cc55 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut { DriveSync, DriveTaskResult, DriveApplyPermission, + DriveSecureLabelList, + DriveSecureLabelUpdate, DriveSearch, DriveInspect, } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 3707fc096..6f170ce3e 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+sync", "+task_result", "+apply-permission", + "+secure-label-list", + "+secure-label-update", "+search", "+inspect", } diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 485a133dd..55b50406e 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -272,6 +272,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | | [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document | | [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) | +| [`+secure-label-list`](references/lark-drive-secure-label.md) | List secure labels available to the current user | +| [`+secure-label-update`](references/lark-drive-secure-label.md) | Update a Drive file/document secure label; downgrade approval errors require opening the document UI | ## API Resources diff --git a/skills/lark-drive/references/lark-drive-secure-label.md b/skills/lark-drive/references/lark-drive-secure-label.md new file mode 100644 index 000000000..a8790d16c --- /dev/null +++ b/skills/lark-drive/references/lark-drive-secure-label.md @@ -0,0 +1,52 @@ +# drive +secure-label-list / +secure-label-update(云文档密级标签) + +## 何时使用 + +- `drive +secure-label-list`:查询当前用户可用的密级标签,先拿到目标 `id`。 +- `drive +secure-label-update`:把目标云文档调整为指定密级标签。 + +这两个 shortcut 都使用用户身份(`--as user`)。修改密级前,通常先执行 `+secure-label-list` 确认可用标签 ID。 + +## 查询可用密级标签 + +```bash +lark-cli drive +secure-label-list --page-size 10 --lang zh +``` + +可选参数: + +| 参数 | 说明 | +|------|------| +| `--page-size` | 分页大小,范围 `1..10`,默认 `10` | +| `--page-token` | 上一页响应里的 `page_token` | +| `--lang` | 标签语言:`zh`、`en`、`ja` | + +底层接口:`GET /open-apis/drive/v2/my_secure_labels`。 + +## 修改文档密级 + +```bash +lark-cli drive +secure-label-update \ + --token "https://example.feishu.cn/docx/doxcnxxxx" \ + --label-id "7217780879644737539" +``` + +参数: + +| 参数 | 说明 | +|------|------| +| `--token` | 目标文档 URL 或 bare token;URL 可自动推断 `--type` | +| `--type` | bare token 必填;URL 输入时可省略。可选:`doc`、`docx`、`sheet`、`file`、`bitable`、`mindnote`、`slides` | +| `--label-id` | 要设置的密级标签 ID | + +底层接口:`PATCH /open-apis/drive/v2/files/:file_token/secure_label`,query 参数 `type`,请求体 `{ "id": "" }`。 + +## 错误处理 + +CLI 不会在 shortcut 中为密级错误码追加专用 hint;agent 必须根据返回的 `error.code` 做以下引导。 + +| 错误码 | 含义 | 引导 | +|--------|------|------| +| `1063013` | 密级降级需要审批 | 提示用户打开目标文档,在文档界面完成密级降级审批后重试;如果用户传入的是文档 URL,必须把该 URL 一并给用户作为操作入口 | + +遇到 `1063013` 时,不要继续重试 API,也不要提示补 scope;这是文档侧审批流程要求,需要用户到文档里操作。 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 337323cbc..2fcd00490 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -1,9 +1,9 @@ # Drive CLI E2E Coverage ## Metrics -- Denominator: 29 leaf commands -- Covered: 8 -- Coverage: 27.6% +- Denominator: 31 leaf commands +- Covered: 10 +- Coverage: 32.3% ## 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`. @@ -11,6 +11,7 @@ - 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 `duplicate_remote_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: 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. +- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows. - TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs. - TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary. - TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary. @@ -32,6 +33,8 @@ | ✕ | drive +move | shortcut | | none | no move workflow yet | | ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | | ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status | +| ✓ | drive +secure-label-list | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--page-size`; `--page-token`; `--lang` | dry-run only; live label availability depends on tenant security-label configuration | +| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level | | ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure | | ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence | | ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet | diff --git a/tests/cli_e2e/drive/drive_secure_label_dryrun_test.go b/tests/cli_e2e/drive/drive_secure_label_dryrun_test.go new file mode 100644 index 000000000..2ebcba660 --- /dev/null +++ b/tests/cli_e2e/drive/drive_secure_label_dryrun_test.go @@ -0,0 +1,98 @@ +// 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" + "github.com/tidwall/gjson" +) + +func TestDrive_SecureLabelDryRun(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 + wantMethod string + wantURL string + assert func(t *testing.T, out string) + }{ + { + name: "list available labels", + args: []string{ + "drive", "+secure-label-list", + "--page-size", "5", + "--page-token", "page_1", + "--lang", "zh", + "--dry-run", + }, + wantMethod: "GET", + wantURL: "/open-apis/drive/v2/my_secure_labels", + assert: func(t *testing.T, out string) { + if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 5 { + t.Fatalf("page_size = %d, want 5\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.page_token").String(); got != "page_1" { + t.Fatalf("page_token = %q, want page_1\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.lang").String(); got != "zh" { + t.Fatalf("lang = %q, want zh\nstdout:\n%s", got, out) + } + }, + }, + { + name: "update label with URL inference", + args: []string{ + "drive", "+secure-label-update", + "--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share", + "--label-id", "7217780879644737539", + "--dry-run", + }, + wantMethod: "PATCH", + wantURL: "/open-apis/drive/v2/files/doxcnE2E001/secure_label", + assert: func(t *testing.T, out string) { + if got := gjson.Get(out, "api.0.params.type").String(); got != "docx" { + t.Fatalf("type = %q, want docx\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.id").String(); got != "7217780879644737539" { + t.Fatalf("body.id = %q, want label id\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "file_token").String(); got != "doxcnE2E001" { + t.Fatalf("file_token = %q, want doxcnE2E001\nstdout:\n%s", got, out) + } + }, + }, + } + + 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 + if got := gjson.Get(out, "api.0.method").String(); got != tt.wantMethod { + t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, 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) + } + tt.assert(t, out) + }) + } +}