Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions shortcuts/drive/drive_secure_label.go
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 56 in shortcuts/drive/drive_secure_label.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_secure_label.go#L56

Added line #L56 was not covered by tests
}
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())

Check warning on line 83 in shortcuts/drive/drive_secure_label.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_secure_label.go#L83

Added line #L83 was not covered by tests
}
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

Check warning on line 95 in shortcuts/drive/drive_secure_label.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_secure_label.go#L95

Added line #L95 was not covered by tests
}
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
}
Comment thread
caojie0621 marked this conversation as resolved.
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)
}
164 changes: 164 additions & 0 deletions shortcuts/drive/drive_secure_label_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions shortcuts/drive/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut {
DriveSync,
DriveTaskResult,
DriveApplyPermission,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,
DriveInspect,
}
Expand Down
2 changes: 2 additions & 0 deletions shortcuts/drive/shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+sync",
"+task_result",
"+apply-permission",
"+secure-label-list",
"+secure-label-update",
"+search",
"+inspect",
}
Expand Down
2 changes: 2 additions & 0 deletions skills/lark-drive/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [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

Expand Down
52 changes: 52 additions & 0 deletions skills/lark-drive/references/lark-drive-secure-label.md
Original file line number Diff line number Diff line change
@@ -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": "<label-id>" }`。

## 错误处理

CLI 不会在 shortcut 中为密级错误码追加专用 hint;agent 必须根据返回的 `error.code` 做以下引导。

| 错误码 | 含义 | 引导 |
|--------|------|------|
| `1063013` | 密级降级需要审批 | 提示用户打开目标文档,在文档界面完成密级降级审批后重试;如果用户传入的是文档 URL,必须把该 URL 一并给用户作为操作入口 |

遇到 `1063013` 时,不要继续重试 API,也不要提示补 scope;这是文档侧审批流程要求,需要用户到文档里操作。
9 changes: 6 additions & 3 deletions tests/cli_e2e/drive/coverage.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# 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`.
- 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, 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.
Expand All @@ -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 |
Expand Down
Loading
Loading