chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
# Drive CLI E2E Coverage
## Metrics
- 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.
- 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.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- 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 / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body 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.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: live export, permission, subscription, reply, and file comment API flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | 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 | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; 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 |
| ✕ | 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 |
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands |
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys update | api | | none | no reply workflow yet |
| ✕ | drive file.comments create_v2 | api | | none | no file comment workflow yet |
| ✕ | drive file.comments list | api | | none | no file comment workflow yet |
| ✕ | drive file.comments patch | api | | none | no file comment workflow yet |
| ✕ | drive file.statistics get | api | | none | no statistics workflow yet |
| ✕ | drive file.view_records list | api | | none | no view-record workflow yet |
| ✕ | drive files copy | api | | none | no file copy workflow yet |
| ✓ | drive files create_folder | api | drive_files_workflow_test.go::TestDrive_FilesCreateFolderWorkflow/create_folder as bot | `name`; empty `folder_token` in `--data` | |
| ✕ | drive files list | api | | none | no list workflow yet |
| ✕ | drive metas batch_query | api | | none | no metadata workflow yet |
| ✕ | drive permission.members auth | api | | none | permission workflows not covered |
| ✕ | drive permission.members create | api | | none | permission workflows not covered |
| ✕ | drive permission.members transfer_owner | api | | none | permission workflows not covered |
| ✕ | drive user remove_subscription | api | | none | subscription workflows not covered |
| ✕ | drive user subscription | api | | none | subscription workflows not covered |
| ✕ | drive user subscription_status | api | | none | subscription workflows not covered |
@@ -0,0 +1,90 @@
// 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 TestDriveAddCommentDryRun_File(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", "+add-comment",
"--doc", "https://example.larksuite.com/file/fileDryRunComment",
"--content", `[{"type":"text","text":"please update README"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(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 := gjson.Get(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 := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" {
t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
}
if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() {
t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" {
t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
}
}
func TestDriveAddCommentDryRun_Base(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", "+add-comment",
"--doc", "https://example.larksuite.com/base/baseDryRunComment",
"--content", `[{"type":"text","text":"please check this record"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/baseDryRunComment/new_comments" {
t.Fatalf("api.0.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_type").String(); got != "bitable" {
t.Fatalf("api.0.body.file_type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.block_id").String(); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("api.0.body.anchor.block_id=%q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.base_record_id").String(); got != "recBIBgGmb" {
t.Fatalf("api.0.body.anchor.base_record_id=%q, want recBIBgGmb\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.base_view_id").String(); got != "vewc46MG1R" {
t.Fatalf("api.0.body.anchor.base_view_id=%q, want vewc46MG1R\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-drive-comment-" + suffix + ".md"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# Comment target\n\nbody\n",
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete file comment target "+fileToken, deleteResult, deleteErr)
})
commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", fileToken,
"--type", "file",
"--content", `[{"type":"text","text":"please update README"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
commentResult.AssertExitCode(t, 0)
commentResult.AssertStdoutStatus(t, true)
commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String()
require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout)
if got := gjson.Get(commentResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_name").String(); got != fileName {
t.Fatalf("data.file_name=%q, want %q\nstdout:\n%s", got, fileName, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
}
@@ -0,0 +1,193 @@
// 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) {
// 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")
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",
},
{
// 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{
"drive", "+apply-permission",
"--token", "bscE2E004",
"--type", "bitable",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/bscE2E004/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) {
// 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")
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)
}
}
@@ -0,0 +1,277 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string {
t.Helper()
stagePath := filepath.Join(workDir, stageName)
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
t.Fatalf("write stage file %s: %v", stageName, err)
}
t.Cleanup(func() { _ = os.Remove(stagePath) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stageName,
"--folder-token", folderToken,
"--name", remoteName,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
return fileToken
}
t.Run("status and pull handle duplicate remote files", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first")
secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second")
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
if statusResult.ExitCode == 0 {
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
if !strings.Contains(statusResult.Stderr, `"type": "validation"`) ||
!strings.Contains(statusResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+status stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
if pullFailResult.ExitCode == 0 {
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if !strings.Contains(pullFailResult.Stderr, `"type": "validation"`) ||
!strings.Contains(pullFailResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+pull stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)
}
pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-duplicate-remote", "rename",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
pullRenameResult.AssertExitCode(t, 0)
pullRenameResult.AssertStdoutStatus(t, true)
items := gjson.Get(pullRenameResult.Stdout, "data.items")
if items.Array() == nil || len(items.Array()) != 2 {
t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout)
}
if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 {
t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout)
}
relPaths := []string{
gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(),
gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(),
}
var renamedRel string
for _, rel := range relPaths {
if rel != "dup.txt" {
renamedRel = rel
}
}
if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") {
t.Fatalf("renamed rel_path = %q, want dup__lark_<hash>.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout)
}
if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) &&
!strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) {
t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout)
}
if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) {
t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout)
}
require.FileExists(t, filepath.Join(workDir, "local", "dup.txt"))
require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel)))
})
t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil {
t.Fatalf("write local dup.txt: %v", err)
}
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first")
time.Sleep(1200 * time.Millisecond)
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
pushResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", folderToken,
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
},
WorkDir: workDir,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
pushResult.AssertExitCode(t, 0)
pushResult.AssertStdoutStatus(t, true)
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 {
t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" {
t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
}
})
t.Run("push overwrites nested remote file under its real parent", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-nested-push-"+suffix, "")
subFolderToken := createDriveFolder(t, parentT, ctx, "sub", folderToken)
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local", "sub"), 0o755); err != nil {
t.Fatalf("mkdir local/sub: %v", err)
}
if err := os.WriteFile(filepath.Join(workDir, "local", "sub", "keep.txt"), []byte("local-nested-overwrite"), 0o644); err != nil {
t.Fatalf("write local/sub/keep.txt: %v", err)
}
existingToken := uploadNamedFile(t, workDir, subFolderToken, "_nested_keep.txt", "keep.txt", "remote-before")
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", folderToken,
"--if-exists", "overwrite",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
pushResult.AssertExitCode(t, 0)
pushResult.AssertStdoutStatus(t, true)
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
t.Fatalf("nested +push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").action`).String(); got != "overwritten" {
t.Fatalf("nested +push action=%q, want overwritten\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").file_token`).String(); got != existingToken {
t.Fatalf("nested +push file_token=%q, want existing token %q\nstdout:\n%s", got, existingToken, pushResult.Stdout)
}
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
t.Fatalf("nested +status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "sub/keep.txt" {
t.Fatalf("nested +status unchanged rel_path=%q, want sub/keep.txt\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
t.Fatalf("nested overwrite should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
}
})
}
@@ -0,0 +1,145 @@
// 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 TestDriveExportDryRun_FileNameMetadata(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", "+export",
"--token", "docxDryRunExport",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "custom-report",
"--output-dir", "./exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
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 != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "docxDryRunExport" {
t.Fatalf("body.token=%q, want docxDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "docx" {
t.Fatalf("body.type=%q, want docx\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "pdf" {
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.file_name").Exists() {
t.Fatalf("file_name should stay local metadata, not export_tasks body\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "custom-report.pdf" {
t.Fatalf("file_name=%q, want custom-report.pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./exports" {
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_MarkdownFetchAPI(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", "+export",
"--token", "docxMdDryRun",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "my-notes",
"--output-dir", "./md-exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
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 != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.extra_param").Exists() {
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_BitableBaseOnlySchema(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", "+export",
"--token", "bitableDryRunExport",
"--doc-type", "bitable",
"--file-extension", "base",
"--only-schema",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
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 != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
t.Fatalf("body.token=%q, want bitableDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "bitable" {
t.Fatalf("body.type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "base" {
t.Fatalf("body.file_extension=%q, want base\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.only_schema").Bool(); !got {
t.Fatalf("body.only_schema=%v, want true\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,31 @@
// 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"
)
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
parentFolderName := "lark-cli-e2e-drive-parent-" + suffix
folderName := "lark-cli-e2e-drive-folder-" + suffix
parentFolderToken := createDriveFolder(t, parentT, ctx, parentFolderName, "")
t.Run("create_folder as bot", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, folderName, parentFolderToken)
if folderToken == "" {
t.Fatalf("folder token should be available")
}
})
}
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
setDriveDryRunConfigEnv(t)
workDir := t.TempDir()
if err := os.WriteFile(workDir+"/notes.md", []byte("# dry run\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "fldcnImportDryRunTarget",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,255 @@
// 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"
)
// --- Happy path: all supported URL types ---
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/docx/doxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DocURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/doc/doccnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_SheetURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/sheets/shtcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_BitableURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/base/bascnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_FileURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/file/boxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/file/boxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_FolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/drive/folder/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoChatDriveFolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/chat/drive/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/shr/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_MindnoteURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/mindnote/mncnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_SlidesURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/slides/slkcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
// --- Wiki URL: two-step flow ---
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "https://xxx.feishu.cn/wiki/wikcnDryRunE2E",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "api.#").Int(),
"expected exactly 2 dry-run API steps for wiki URL, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/wiki/v2/spaces/get_node",
gjson.Get(result.Stdout, "api.0.url").String(),
"expected get_node as first step, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
gjson.Get(result.Stdout, "api.1.url").String(),
"expected batch_query as second step, stdout:\n%s", result.Stdout)
}
// --- Bare token with --type ---
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "doxcnBareToken",
"--type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assertOneStepBatchQuery(t, result)
}
// --- Validation errors ---
func TestDriveInspectValidation_EmptyURL(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "--url cannot be empty",
"expected empty URL validation error, stderr:\n%s", result.Stderr)
}
func TestDriveInspectValidation_UnsupportedURL(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "https://google.com/some/page",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "unsupported --url",
"expected unsupported URL validation error, stderr:\n%s", result.Stderr)
}
func TestDriveInspectValidation_BareTokenWithoutType(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "doxcnBareToken",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "--type is required when --url is a bare token",
"expected bare-token-without-type validation error, stderr:\n%s", result.Stderr)
}
func TestDriveInspectValidation_InvalidType(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "someToken",
"--type", "invalid_type",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "invalid_type",
"expected invalid type validation error, stderr:\n%s", result.Stderr)
}
// --- Helpers ---
func runInspectDryRun(t *testing.T, url string) *clie2e.Result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", url,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result
}
func assertOneStepBatchQuery(t *testing.T, result *clie2e.Result) {
t.Helper()
require.Equal(t, int64(1), gjson.Get(result.Stdout, "api.#").Int(),
"expected exactly 1 dry-run API step, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
gjson.Get(result.Stdout, "api.0.url").String(),
"expected batch_query URL, stdout:\n%s", result.Stdout)
}
func setDriveInspectE2EEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_inspect_e2e_app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_inspect_e2e_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,499 @@
// 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"
)
// TestDrive_MemberAddDryRun locks in the request shape the shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end (so flag parsing,
// validation, and dry-run rendering all execute), and the printed request is
// inspected to confirm
// - HTTP method, URL template, and token path segment,
// - type query parameter (auto-inferred from a URL input, explicit for a
// bare token input),
// - explicit --type overriding URL inference,
// - member_type / member kind / perm / perm_type body fields,
// - single-member vs batch endpoint selection.
//
// Fake credentials are sufficient because --dry-run short-circuits before any
// network call.
func TestDrive_MemberAddDryRun(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")
tests := []struct {
name string
args []string
wantURL string
wantResourceType string
wantNeedNotification string
wantMemberID string
wantMemberType string
wantPerm string
wantMemberKind string
wantPermType string
wantBatch bool
}{
{
name: "URL input auto-infers docx type",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--perm", "view",
"--need-notification=false",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members",
wantResourceType: "docx",
wantNeedNotification: "false",
wantMemberID: "ou_e2e_user",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "URL input auto-infers mindnote type from mindnotes path",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/mindnotes/mndE2E011?from=share",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/mndE2E011/members",
wantResourceType: "mindnote",
wantMemberID: "ou_e2e_user",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "bare token with explicit wiki type defaults perm_type container",
args: []string{
"drive", "+member-add",
"--token", "wikcnE2E002",
"--type", "wiki",
"--member-id", "ou_e2e_admin",
"--member-type", "openid",
"--perm", "full_access",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members",
wantResourceType: "wiki",
wantMemberID: "ou_e2e_admin",
wantMemberType: "openid",
wantPerm: "full_access",
wantMemberKind: "user",
wantPermType: "container",
},
{
// Explicit --type must override URL inference: the /docx/ marker
// would infer type=docx, but the caller asked to grant permission
// against a wiki node. The URL token itself is still used as the
// path token.
name: "explicit --type overrides URL inference",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/docx/doxcnE2E009",
"--type", "wiki",
"--member-id", "ou_e2e_override",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E009/members",
wantResourceType: "wiki",
wantMemberID: "ou_e2e_override",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
wantPermType: "container",
},
{
name: "bare token with explicit folder type",
args: []string{
"drive", "+member-add",
"--token", "fldE2E010",
"--type", "folder",
"--member-id", "ou_e2e_folder",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/fldE2E010/members",
wantResourceType: "folder",
wantMemberID: "ou_e2e_folder",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "email member-type",
args: []string{
"drive", "+member-add",
"--token", "shtcnE2E003",
"--type", "sheet",
"--member-id", "user@example.com",
"--member-type", "email",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E003/members",
wantResourceType: "sheet",
wantMemberID: "user@example.com",
wantMemberType: "email",
wantPerm: "edit",
wantMemberKind: "user",
},
{
name: "unionid member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E006",
"--type", "docx",
"--member-id", "on_e2e_union",
"--member-type", "unionid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E006/members",
wantResourceType: "docx",
wantMemberID: "on_e2e_union",
wantMemberType: "unionid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "explicit-only groupid member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E007",
"--type", "docx",
"--member-id", "group_e2e",
"--member-type", "groupid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E007/members",
wantResourceType: "docx",
wantMemberID: "group_e2e",
wantMemberType: "groupid",
wantPerm: "view",
wantMemberKind: "group",
},
{
name: "batch members use batch_create endpoint",
args: []string{
"drive", "+member-add",
"--token", "bascnE2E004",
"--type", "bitable",
"--member-id", "ou_a,ou_b",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/bascnE2E004/members/batch_create",
wantResourceType: "bitable",
wantMemberID: "ou_a",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
wantBatch: true,
},
{
name: "explicit groupid member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E005",
"--type", "docx",
"--member-id", "grp_abc",
"--member-type", "groupid",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E005/members",
wantResourceType: "docx",
wantMemberID: "grp_abc",
wantMemberType: "groupid",
wantPerm: "edit",
wantMemberKind: "group",
},
{
name: "appid member-type is passed through",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E008",
"--type", "docx",
"--member-id", "cli_app_123",
"--member-type", "appid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E008/members",
wantResourceType: "docx",
wantMemberID: "cli_app_123",
wantMemberType: "appid",
wantPerm: "view",
},
{
name: "wikispaceid member-type requires wiki-space body type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E012",
"--type", "docx",
"--member-id", "spc_e2e_wiki",
"--member-type", "wikispaceid",
"--member-kind", "wiki_space_editor",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E012/members",
wantResourceType: "docx",
wantMemberID: "spc_e2e_wiki",
wantMemberType: "wikispaceid",
wantPerm: "view",
wantMemberKind: "wiki_space_editor",
},
}
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 != "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.wantResourceType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out)
}
notification := gjson.Get(out, "api.0.params.need_notification")
if tt.wantNeedNotification == "" {
if notification.Exists() {
t.Fatalf("need_notification should be omitted\nstdout:\n%s", out)
}
} else if got := notification.String(); got != tt.wantNeedNotification {
t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out)
}
bodyPath := "api.0.body"
if tt.wantBatch {
bodyPath = "api.0.body.members.0"
if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 {
t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out)
}
}
if got := gjson.Get(out, bodyPath+".member_id").String(); got != tt.wantMemberID {
t.Fatalf("body.member_id = %q, want %q\nstdout:\n%s", got, tt.wantMemberID, out)
}
if got := gjson.Get(out, bodyPath+".member_type").String(); got != tt.wantMemberType {
t.Fatalf("body.member_type = %q, want %q\nstdout:\n%s", got, tt.wantMemberType, out)
}
if got := gjson.Get(out, bodyPath+".perm").String(); got != tt.wantPerm {
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
}
if got := gjson.Get(out, bodyPath+".type").String(); got != tt.wantMemberKind {
t.Fatalf("body.type = %q, want %q\nstdout:\n%s", got, tt.wantMemberKind, out)
}
permType := gjson.Get(out, bodyPath+".perm_type")
if tt.wantPermType == "" {
if permType.Exists() {
t.Fatalf("perm_type should be omitted\nstdout:\n%s", out)
}
} else if got := permType.String(); got != tt.wantPermType {
t.Fatalf("body.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
}
})
}
}
func TestDrive_MemberAddDryRunRejectsInvalidInputs(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")
tests := []struct {
name string
args []string
defaultAs string
wantErr string
}{
{
name: "any host accepted",
args: []string{
"drive", "+member-add",
"--token", "https://google.com/docx/doxcnE2E001",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
},
{
name: "unsupported URL path",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/calendar/calE2E001",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
wantErr: "unsupported URL path",
},
{
name: "bare token requires explicit type",
args: []string{
"drive", "+member-add",
"--token", "unknownE2E001",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
wantErr: "--type is required when --token is a bare token",
},
{
name: "duplicate member IDs are rejected",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "ou_a,ou_b,ou_a",
"--member-type", "openid",
"--dry-run",
},
wantErr: "duplicate collaborator ID",
},
{
name: "invalid explicit type is rejected",
args: []string{
"drive", "+member-add",
"--token", "mincnE2E001",
"--type", "invalidtype",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
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",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "ou_e2e_user,oc_e2e_chat",
"--member-type", "openid",
"--dry-run",
},
wantErr: "implies --member-type openchat",
},
{
name: "explicit member-type conflicts with member-id prefix",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "oc_e2e_chat",
"--member-type", "openid",
"--dry-run",
},
wantErr: "implies --member-type openchat",
},
{
name: "department collaborator requires user identity",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "od_e2e_dept",
"--member-type", "opendepartmentid",
"--dry-run",
},
defaultAs: "bot",
wantErr: "--member-type=opendepartmentid requires --as user",
},
{
name: "wikispaceid requires member-kind",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "spc_e2e_wiki",
"--member-type", "wikispaceid",
"--dry-run",
},
wantErr: "--member-kind is required when --member-type=wikispaceid",
},
{
name: "member-kind only applies to wikispaceid",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--member-kind", "wiki_space_member",
"--dry-run",
},
wantErr: "--member-kind only applies when --member-type=wikispaceid",
},
}
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)
defaultAs := tt.defaultAs
if defaultAs == "" {
defaultAs = "user"
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: defaultAs,
})
require.NoError(t, err)
if tt.wantErr == "" {
result.AssertExitCode(t, 0)
return
}
result.AssertExitCode(t, 2)
output := result.Stdout + result.Stderr
require.Contains(t, output, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
}
@@ -0,0 +1,146 @@
// 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"
)
// TestDrivePreviewDryRun_ListOnly verifies preview dry-run request structure
// for list mode.
func TestDrivePreviewDryRun_ListOnly(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", "+preview",
"--file-token", "fileDryRunPreview",
"--list-only",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
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 != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_result" {
t.Fatalf("url=%q, want preview_result endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "mode").String(); got != "list" {
t.Fatalf("mode=%q, want list\nstdout:\n%s", got, out)
}
}
// TestDrivePreviewDryRun_Download verifies preview dry-run request structure
// for download mode.
func TestDrivePreviewDryRun_Download(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", "+preview",
"--file-token", "fileDryRunPreview",
"--type", "pdf",
"--version", "12",
"--output", "./artifacts/report",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.#").Int(); got != 2 {
t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.version").String(); got != "12" {
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.method").String(); got != "GET" {
t.Fatalf("download method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
t.Fatalf("download url=%q, want preview_download endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.preview_type").String(); got != "<selected type_code from preview_result>" {
t.Fatalf("preview_type=%q, want placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.version").String(); got != "12" {
t.Fatalf("download version=%q, want 12\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "requested_type").String(); got != "pdf" {
t.Fatalf("requested_type=%q, want pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output").String(); got != "./artifacts/report" {
t.Fatalf("output=%q, want ./artifacts/report\nstdout:\n%s", got, out)
}
}
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
// download mode.
func TestDriveCoverDryRun_Download(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", "+cover",
"--file-token", "fileDryRunCover",
"--spec", "square",
"--output", "./artifacts/cover",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunCover/preview_download" {
t.Fatalf("url=%q, want preview_download endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.preview_type").String(); got != "1" {
t.Fatalf("preview_type=%q, want 1\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.bus_type").Exists(); got {
t.Fatalf("bus_type should be omitted for square crop flow\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.platform").Exists(); got {
t.Fatalf("platform should be omitted when using default platform\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.width").String(); got != "360" {
t.Fatalf("width=%q, want 360\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.height").String(); got != "360" {
t.Fatalf("height=%q, want 360\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.policy").String(); got != "near" {
t.Fatalf("policy=%q, want near\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "selected_spec").String(); got != "square" {
t.Fatalf("selected_spec=%q, want square\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PreviewAndCoverWorkflow verifies preview and cover shortcuts against
// a live Drive workflow, skipping when required bot scopes are unavailable.
func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-preview-" + suffix
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
workDir := t.TempDir()
sourceRelPath := "fixture/report.txt"
sourceContent := "drive preview and cover workflow\n"
writePreviewFixture(t, workDir, sourceRelPath, sourceContent)
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
t.Run("preview list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--list-only",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
Attempts: 8,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
return !previewListContainsReadyType(result.Stdout, "text")
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if !previewListContainsReadyType(listResult.Stdout, "text") {
t.Fatalf("preview list did not expose downloadable text preview\nstdout:\n%s", listResult.Stdout)
}
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--type", "text",
"--output", "./artifacts/report-preview",
},
WorkDir: downloadDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
stdout := downloadResult.Stdout
if got := gjson.Get(stdout, "data.selected_type").String(); got != "text" {
t.Fatalf("selected_type=%q, want text\nstdout:\n%s", got, stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "preview download should return output_path")
if ext := filepath.Ext(outputPath); ext != ".txt" {
t.Fatalf("preview output extension=%q, want .txt\nstdout:\n%s", ext, stdout)
}
data, readErr := os.ReadFile(outputPath)
require.NoError(t, readErr)
if !strings.Contains(string(data), "drive preview and cover workflow") {
t.Fatalf("preview artifact content mismatch: %q", string(data))
}
})
t.Run("cover list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+cover",
"--file-token", fileToken,
"--list-only",
},
DefaultAs: "bot",
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if !gjson.Get(listResult.Stdout, `data.candidates.#(spec=="default")`).Exists() {
t.Fatalf("cover list missing default spec\nstdout:\n%s", listResult.Stdout)
}
downloadDir := t.TempDir()
coverResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+cover",
"--file-token", fileToken,
"--spec", "default",
"--output", "./artifacts/report-cover",
},
WorkDir: downloadDir,
DefaultAs: "bot",
}, clie2e.RetryOptions{
Attempts: 8,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil {
return true
}
if result.ExitCode == 0 {
return false
}
return false
},
})
require.NoError(t, err)
coverResult.AssertExitCode(t, 0)
coverResult.AssertStdoutStatus(t, true)
stdout := coverResult.Stdout
if got := gjson.Get(stdout, "data.selected_spec").String(); got != "default" {
t.Fatalf("selected_spec=%q, want default\nstdout:\n%s", got, stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "cover download should return output_path")
if ext := filepath.Ext(outputPath); ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
t.Fatalf("cover output extension=%q, want image extension\nstdout:\n%s", ext, stdout)
}
info, statErr := os.Stat(outputPath)
require.NoError(t, statErr)
if info.Size() <= 0 {
t.Fatalf("cover artifact should not be empty: %s", outputPath)
}
})
}
// writePreviewFixture writes a local fixture file used by the live workflow.
func writePreviewFixture(t *testing.T, workDir, relPath, content string) {
t.Helper()
fullPath := filepath.Join(workDir, relPath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
t.Fatalf("mkdir fixture parent: %v", err)
}
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
// uploadPreviewFixture uploads a fixture into Drive and registers cleanup for
// the created file token.
func uploadPreviewFixture(t *testing.T, parentT *testing.T, ctx context.Context, workDir, folderToken, relPath, uploadName string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", relPath,
"--folder-token", folderToken,
"--name", uploadName,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token")
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// previewListContainsReadyType reports whether a preview list response contains
// a downloadable candidate for the requested type.
func previewListContainsReadyType(stdout, wantType string) bool {
for _, candidate := range gjson.Get(stdout, "data.candidates").Array() {
if candidate.Get("type").String() != wantType {
continue
}
if candidate.Get("downloadable").Bool() {
return true
}
}
return false
}
// createDriveFolderOrSkipPermission creates a Drive folder for the live
// workflow and skips when the bot lacks required folder scopes.
func createDriveFolderOrSkipPermission(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+create-folder", "--name", name},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedOutput, "app scope not enabled") ||
strings.Contains(combinedOutput, "space:folder:create") ||
strings.Contains(combinedOutput, "99991672") {
t.Skipf("skip drive preview/cover workflow due to missing bot scope space:folder:create: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
folderToken := gjson.Get(result.Stdout, "data.folder_token").String()
require.NotEmpty(t, folderToken, "drive folder token should not be empty")
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", folderToken, "--type", "folder", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
})
return folderToken
}
@@ -0,0 +1,251 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PullDryRun locks in the request shape the +pull shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_PullDryRun(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "list --folder-token") {
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_PullDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_PullDryRunRejectsAbsoluteLocalDir(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")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PullDryRunRejectsDeleteLocalWithoutYes locks in the safety
// guard: --delete-local without --yes must be refused upfront, even under
// --dry-run, so an unintended delete flag never silently slides through.
func TestDrive_PullDryRunRejectsDeleteLocalWithoutYes(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("--delete-local without --yes must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--yes") {
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PullDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_PullDryRunRejectsMissingFolderToken(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(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 _, strategy := range []string{"rename", "newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
func TestDrive_PullDryRunAcceptsIfExistsSmart(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--if-exists", "smart",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PushDryRun locks in the request shape the +push shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_PushDryRun(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "list --folder-token") {
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
}
if !strings.Contains(desc, "upload") {
t.Fatalf("description missing upload phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_PushDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_PushDryRunRejectsAbsoluteLocalDir(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")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
// Validate-stage rejection emits ExitValidation (2). A regression
// that reclassified this as a generic api_error (1) or success (0)
// would slip through a loose `!= 0` check, so assert the exact code.
if result.ExitCode != 2 {
t.Fatalf("absolute --local-dir must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes locks in the safety
// guard: --delete-remote without --yes must be refused upfront, even
// under --dry-run, so an unintended delete flag never silently slides
// through.
func TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-remote",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
// Same exact-code reasoning as the absolute-path test: this is a
// Validate-stage rejection so it must surface as ExitValidation (2).
if result.ExitCode != 2 {
t.Fatalf("--delete-remote without --yes must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--yes") {
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PushDryRunAcceptsDeleteRemoteWithYes is the symmetric guard
// to TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes: when --yes is
// passed alongside --delete-remote, Validate must accept the run and
// hand off to the dry-run renderer.
//
// Specifically pins the conditional scope pre-check added to Validate:
// when the resolver has no token / no scope metadata (the e2e setup
// uses fake credentials with no real auth state), runtime.EnsureScopes
// is a silent no-op so dry-run still emits its envelope. A regression
// where the pre-check incorrectly fired against an empty scope list
// would surface here as a non-zero exit and a missing_scope error.
func TestDrive_PushDryRunAcceptsDeleteRemoteWithYes(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-remote",
"--yes",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
// No structured error envelope on stdout/stderr — the conditional
// EnsureScopes call must not trip a missing_scope here.
if strings.Contains(out, `"type": "missing_scope"`) || strings.Contains(result.Stderr, "missing_scope") {
t.Fatalf("conditional scope pre-check fired in a no-credential env\nstdout:\n%s\nstderr:\n%s", out, result.Stderr)
}
}
// TestDrive_PushDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_PushDryRunRejectsMissingFolderToken(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
// A missing cobra required-flag is routed through the typed validation
// envelope (exit 2, invalid_argument) — the same class as the explicit
// flag/subcommand guards, not cobra's plain-text exit 1.
if result.ExitCode != 2 {
t.Fatalf("missing --folder-token must be rejected with exit=2 (typed validation), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(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 _, strategy := range []string{"newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
func TestDrive_PushDryRunAcceptsIfExistsSmart(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--if-exists", "smart",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,377 @@
// 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"
)
// TestDriveSearchDryRun_RequestShape locks in the dry-run request body so
// agents that key off of stdout (URL, doc_filter / wiki_filter, scalar
// filters) don't silently regress. Run end-to-end so cobra flag parsing,
// readDriveSearchSpec, and the dry-run renderer all execute against the
// real binary.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDriveSearchDryRun_RequestShape(t *testing.T) {
setDriveSearchE2EEnv(t)
tests := []struct {
name string
args []string
// JSONPath assertions over the dry-run body.
wantURL string
wantQuery string
wantDocFilter bool
wantWikiFilter bool
wantDocFilterFields map[string]string // gjson path under api.0.body.doc_filter -> string value (or "" to require existence only)
wantWikiFilterFields map[string]string
}{
{
name: "basic --query emits both filters",
args: []string{
"drive", "+search",
"--query", "season report",
"--page-size", "5",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "season report",
wantDocFilter: true,
wantWikiFilter: true,
},
{
name: "--folder-tokens scopes to doc_filter only",
args: []string{
"drive", "+search",
"--query", "x",
"--folder-tokens", "fld_aaa,fld_bbb",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantDocFilterFields: map[string]string{
"folder_tokens.0": "fld_aaa",
"folder_tokens.1": "fld_bbb",
},
},
{
name: "--space-ids scopes to wiki_filter only",
args: []string{
"drive", "+search",
"--query", "x",
"--space-ids", "sp_xxx",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantWikiFilter: true,
wantWikiFilterFields: map[string]string{
"space_ids.0": "sp_xxx",
},
},
{
name: "--sort default maps to DEFAULT_TYPE in body",
args: []string{
"drive", "+search",
"--query", "x",
"--sort", "default",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantWikiFilter: true,
wantDocFilterFields: map[string]string{
"sort_type": "DEFAULT_TYPE",
},
},
{
name: "mixed-case --doc-types is normalized to upper case in body",
args: []string{
"drive", "+search",
"--query", "x",
"--doc-types", "docx,Sheet,BITABLE",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantWikiFilter: true,
wantDocFilterFields: map[string]string{
"doc_types.0": "DOCX",
"doc_types.1": "SHEET",
"doc_types.2": "BITABLE",
},
},
}
for _, tt := range tests {
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 != "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.body.query").String(); got != tt.wantQuery {
t.Fatalf("body.query=%q, want %q\nstdout:\n%s", got, tt.wantQuery, out)
}
if tt.wantDocFilter && !gjson.Get(out, "api.0.body.doc_filter").Exists() {
t.Fatalf("doc_filter missing\nstdout:\n%s", out)
}
if !tt.wantDocFilter && gjson.Get(out, "api.0.body.doc_filter").Exists() {
t.Fatalf("doc_filter should be omitted\nstdout:\n%s", out)
}
if tt.wantWikiFilter && !gjson.Get(out, "api.0.body.wiki_filter").Exists() {
t.Fatalf("wiki_filter missing\nstdout:\n%s", out)
}
if !tt.wantWikiFilter && gjson.Get(out, "api.0.body.wiki_filter").Exists() {
t.Fatalf("wiki_filter should be omitted\nstdout:\n%s", out)
}
for path, want := range tt.wantDocFilterFields {
if got := gjson.Get(out, "api.0.body.doc_filter."+path).String(); got != want {
t.Fatalf("doc_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
}
}
for path, want := range tt.wantWikiFilterFields {
if got := gjson.Get(out, "api.0.body.wiki_filter."+path).String(); got != want {
t.Fatalf("wiki_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
}
}
})
}
}
func TestDriveSearchDryRun_BotIdentity(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "season report",
"--page-size", "5",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Args, "--as")
require.Contains(t, result.Args, "bot")
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/search/v2/doc_wiki/search" {
t.Fatalf("url=%q, want Search v2 doc_wiki/search\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
}
if got := gjson.Get(out, "api.0.body.query").String(); got != "season report" {
t.Fatalf("body.query=%q, want season report\nstdout:\n%s", got, out)
}
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+search", "--help"},
})
require.NoError(t, err)
helpResult.AssertExitCode(t, 0)
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
}
// TestDriveSearchDryRun_OpenedClamping locks in the agent-facing slice
// notice for --opened-* spans over 90 days: the request body must carry
// the most recent 90-day window, and stderr must list slice N's flag
// values verbatim so the agent can re-invoke for older ranges.
func TestDriveSearchDryRun_OpenedClamping(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--opened-since", "8m",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Notice goes to stderr alongside other dimension notices.
for _, want := range []string{
"--opened-* window spans",
"3 slices total",
"[slice 1/3 current]",
"[slice 2/3]",
"[slice 3/3]",
"--page-token",
} {
if !strings.Contains(result.Stderr, want) {
t.Fatalf("notice missing %q\nstderr:\n%s", want, result.Stderr)
}
}
// Slice 1 specifically must spell out concrete --opened-* flag values
// (not just the timestamps in arrow form): an agent paginating slice 1
// has to copy these verbatim, otherwise reusing the original relative
// time '8m' would drift the window against time.Now() and mismatch the
// page_token.
for _, label := range []string{"[slice 1/3 current]", "[slice 2/3]", "[slice 3/3]"} {
var line string
for _, l := range strings.Split(result.Stderr, "\n") {
if strings.Contains(l, label) {
line = l
break
}
}
if !strings.Contains(line, "--opened-since ") || !strings.Contains(line, "--opened-until ") {
t.Fatalf("%s line must spell out both flags, got %q\nfull stderr:\n%s", label, line, result.Stderr)
}
}
// And the request body's open_time must reflect the clamped window
// (start and end both present, span = 90 days exactly).
body := result.Stdout
start := gjson.Get(body, "api.0.body.doc_filter.open_time.start").Int()
end := gjson.Get(body, "api.0.body.doc_filter.open_time.end").Int()
if start == 0 || end == 0 {
t.Fatalf("doc_filter.open_time.start/end missing\nstdout:\n%s", body)
}
if delta := end - start; delta != 90*86400 {
t.Fatalf("clamped span = %d seconds, want %d (90 days)\nstdout:\n%s", delta, 90*86400, body)
}
}
// TestDriveSearchDryRun_RejectsOpenedOver1Year locks in the hard cap: a
// --opened-* span beyond 365 days fails validation up front and never
// reaches the API. Important because the alternative (silent slicing into
// many windows) would produce a rate-limit / runaway request loop.
//
// Dry-run captures spec-level validation errors into the JSON envelope's
// `error` field (api list comes back empty); the process still exits 0
// because the dry-run itself succeeded — it just told you what would have
// failed at execution time.
func TestDriveSearchDryRun_RejectsOpenedOver1Year(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--opened-since", "2y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
if !strings.Contains(errMsg, "365-day") {
t.Fatalf("expected 365-day cap message in dry-run error, got %q\nstdout:\n%s", errMsg, result.Stdout)
}
}
// TestDriveSearchDryRun_RejectsInvalidSort locks in the cobra Enum guard.
// CLI intentionally exposes only 5 sort values (default, edit_time,
// edit_time_asc, open_time, create_time); the deprecated /
// not-supported server enum values must be rejected before reaching the
// request layer.
func TestDriveSearchDryRun_RejectsInvalidSort(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--sort", "create_time_asc",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("invalid sort must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
// Pin to the flag name (with dashes) rather than the bare word "sort",
// which would also match "transport" / "sortable" / etc.
if !strings.Contains(combined, "--sort") {
t.Fatalf("expected --sort error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDriveSearchDryRun_RejectsBadDocType verifies the doc-types validator
// is wired at the dry-run path: an unknown enum value surfaces as a
// validation error inside the dry-run JSON envelope rather than reaching
// the server. The process still exits 0 (see RejectsOpenedOver1Year).
func TestDriveSearchDryRun_RejectsBadDocType(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--doc-types", "docx,pie",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
if !strings.Contains(errMsg, "--doc-types") {
t.Fatalf("expected --doc-types error in dry-run, got %q\nstdout:\n%s", errMsg, result.Stdout)
}
}
func setDriveSearchE2EEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_search_e2e_app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_search_e2e_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -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)
})
}
}
@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusDryRun locks in the request shape the +status shortcut
// emits under --dry-run: the real CLI binary is invoked end-to-end, so the
// full flag-parsing, Validate (which still runs in dry-run mode), and the
// dry-run renderer all execute. The printed envelope is then inspected to
// confirm the GET method, list-files URL, and folder_token parameter, plus
// the descriptive text from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDrive_StatusDryRun(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")
// Validate runs even under --dry-run, so we need a real --local-dir
// inside the working directory; create one in a temp tree.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "Walk --local-dir") || !strings.Contains(desc, "SHA-256") {
t.Fatalf("description missing key phrases, got %q\nstdout:\n%s", desc, out)
}
}
func TestDrive_StatusDryRunQuick(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--quick",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") {
t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
// --local-dir path validator runs in the real binary's Validate stage and
// surfaces a structured error referencing --local-dir (not the framework
// default --file).
func TestDrive_StatusDryRunRejectsAbsoluteLocalDir(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")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_StatusDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_StatusDryRunRejectsMissingFolderToken(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
@@ -0,0 +1,464 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusWorkflow exercises +status against a real Drive folder so
// the parts that dry-run can't reach — recursive listing pagination, the
// download+hash leg, scope handling, and the SHA-256 comparison itself —
// are covered against the real backend.
//
// Layout:
//
// folder/ (--folder-token target)
// ├── unchanged.txt "match" ↔ local: "match" → unchanged
// ├── modified.txt "remote" ↔ local: "local" → modified
// └── remote-only.txt "remote" ↔ (none) → new_remote
// local/ (--local-dir target)
// ├── unchanged.txt "match"
// ├── modified.txt "local"
// └── local-only.txt "anything" → new_local
//
// Expected output: each of the four buckets contains exactly the file we
// expect, with file_token set for the three buckets that have a Drive side.
func TestDrive_StatusWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
// Local working directory. +status's --local-dir must be relative to
// the binary's cwd, so each upload + the +status invocation share the
// same WorkDir.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
// Helper: write a local file under workDir/<rel>.
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
// Helper: stage <content> into a sibling temp file then upload it as
// <name> under folderToken. +upload reads --file relative to its cwd.
uploadDriveFile := func(name, content string) string {
t.Helper()
// Stage outside `local/` so the local-side tree only sees what
// the test wants; +upload still reads relative to workDir.
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// Seed both sides. Order doesn't matter functionally, but doing the
// uploads first lets the +status listing pick up everything in a
// single pass.
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokModified := uploadDriveFile("modified.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match") // matches remote → unchanged
writeLocal("local/modified.txt", "local") // differs → modified
writeLocal("local/local-only.txt", "extra") // only here → new_local
// Run +status against the real folder.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Assert each bucket contains exactly the file we expect, with the
// correct file_token for sides that have one.
out := result.Stdout
cases := []struct {
bucket string
path string
token string // empty when the bucket has no Drive side
}{
{"unchanged", "unchanged.txt", tokUnchanged},
{"modified", "modified.txt", tokModified},
{"new_local", "local-only.txt", ""},
{"new_remote", "remote-only.txt", tokRemoteOnly},
}
for _, c := range cases {
bucket := gjson.Get(out, "data."+c.bucket)
if !bucket.IsArray() {
t.Fatalf("data.%s must be an array, stdout:\n%s", c.bucket, out)
}
var found bool
bucket.ForEach(func(_, entry gjson.Result) bool {
if entry.Get("rel_path").String() != c.path {
return true // continue
}
found = true
if c.token != "" {
if got := entry.Get("file_token").String(); got != c.token {
t.Errorf("%s entry %q: file_token=%q want %q", c.bucket, c.path, got, c.token)
}
} else if entry.Get("file_token").String() != "" {
t.Errorf("%s entry %q must not carry file_token (local-only), stdout:\n%s", c.bucket, c.path, out)
}
return false // stop
})
if !found {
t.Errorf("%s bucket missing %q\nstdout:\n%s", c.bucket, c.path, out)
}
}
// Make sure each bucket is exactly the size we expect (4 files total,
// no double-bucketing). +upload may attach extra metadata (e.g. a
// folder type entry for `local/` itself) but the lister filters
// type=file so the buckets should be clean.
for _, b := range []struct {
bucket string
want int
}{
{"unchanged", 1},
{"modified", 1},
{"new_local", 1},
{"new_remote", 1},
} {
got := int(gjson.Get(out, "data."+b.bucket+".#").Int())
if got != b.want {
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
}
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
}
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
if sharedCount != 2 {
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
}
for _, path := range []string{"unchanged.txt", "modified.txt"} {
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
}
}
}
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
// semantics on the live backend instead of silently behaving like the default
// exact hash mode.
//
// The fixture intentionally makes the two shared files diverge in opposite ways:
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
//
// This locks in the best-effort nature of quick mode with real Drive
// modified_time values fetched from the list API, plus the expected new_local /
// new_remote buckets.
func TestDrive_StatusQuickWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
sameMtimeRemote := remoteFiles["same-mtime.txt"]
remoteNewer := remoteFiles["remote-newer.txt"]
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
}
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
writeLocal("local/local-only.txt", "local only") // local-only bucket
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
t.Fatalf("chtimes same-mtime.txt: %v", err)
}
localOlder := remoteNewerAt.Add(-2 * time.Second)
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
t.Fatalf("chtimes remote-newer.txt: %v", err)
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, quickOut, "unchanged", 1)
assertStatusBucketLen(t, quickOut, "modified", 1)
assertStatusBucketLen(t, quickOut, "new_local", 1)
assertStatusBucketLen(t, quickOut, "new_remote", 1)
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, exactResult)
exactResult.AssertExitCode(t, 0)
exactResult.AssertStdoutStatus(t, true)
exactOut := exactResult.Stdout
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
}
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, exactOut, "unchanged", 1)
assertStatusBucketLen(t, exactOut, "modified", 1)
assertStatusBucketLen(t, exactOut, "new_local", 1)
assertStatusBucketLen(t, exactOut, "new_remote", 1)
}
type driveStatusListedFile struct {
Token string
ModifiedTime string
}
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
t.Helper()
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "list", "--params", params},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
files := make(map[string]driveStatusListedFile)
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
name := entry.Get("name").String()
if name == "" {
return true
}
files[name] = driveStatusListedFile{
Token: entry.Get("token").String(),
ModifiedTime: entry.Get("modified_time").String(),
}
return true
})
return files
}
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
t.Helper()
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
t.Fatalf("parse Drive epoch %q: %v", raw, err)
}
switch {
case v > 1e14 || v < -1e14:
return time.UnixMicro(v)
case v > 1e11 || v < -1e11:
return time.UnixMilli(v)
default:
return time.Unix(v, 0)
}
}
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
t.Helper()
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
if !entry.Exists() {
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
}
if fileToken == "" {
if got := entry.Get("file_token").String(); got != "" {
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
}
return
}
if got := entry.Get("file_token").String(); got != fileToken {
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
}
}
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
t.Helper()
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
}
}
func skipDriveStatusExactIfMissingDownloadScope(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
combinedLower := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedLower, "missing_scope") && strings.Contains(combinedLower, "drive:file:download") {
t.Skipf("skip drive +status exact live workflow due to missing drive:file:download scope: stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_SyncDryRun locks in the request shape the +sync shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_SyncDryRun(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "diff") {
t.Fatalf("description missing diff phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_SyncDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_SyncDryRunRejectsAbsoluteLocalDir(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")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_SyncDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_SyncDryRunRejectsMissingFolderToken(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_SyncDryRunAcceptsConflictStrategies verifies that all valid
// --on-conflict values pass Validate and produce a well-formed dry-run
// envelope.
func TestDrive_SyncDryRunAcceptsConflictStrategies(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 _, strategy := range []string{"remote-wins", "local-wins", "keep-both", "ask"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-conflict", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
// TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies verifies that all
// valid --on-duplicate-remote values pass Validate.
func TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies(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 _, strategy := range []string{"fail", "newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
// TestDrive_SyncDryRunAcceptsQuickFlag verifies that --quick passes Validate
// and produces a well-formed dry-run envelope.
func TestDrive_SyncDryRunAcceptsQuickFlag(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")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--quick",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,346 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_SyncWorkflow exercises +sync against a real Drive folder, proving
// that new_remote files are pulled, new_local files are pushed, and conflicts
// are resolved according to --on-conflict.
//
// Layout (before sync):
//
// folder/ (--folder-token target)
// ├── remote-only.txt "remote" ↔ (none) → new_remote → pull
// ├── conflict.txt "remote" ↔ local: "local" → modified → resolve
// └── unchanged.txt "match" ↔ local: "match" → unchanged → skip
// local/ (--local-dir target)
// ├── local-only.txt "local" → new_local → push
// ├── conflict.txt "local" → modified → resolve
// └── unchanged.txt "match" → unchanged → skip
func TestDrive_SyncWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// --- Subtest: remote-wins (default) ---
t.Run("remote-wins pulls new_remote and overwrites on conflict", func(t *testing.T) {
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokConflict := uploadDriveFile("conflict.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match")
writeLocal("local/conflict.txt", "local")
writeLocal("local/local-only.txt", "local")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "remote-wins",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Summary checks.
if got := gjson.Get(out, "data.summary.pulled").Int(); got != 2 {
t.Fatalf("pulled=%d want 2 (remote-only + conflict resolved by pull)\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.summary.pushed").Int(); got < 1 {
t.Fatalf("pushed=%d want >=1 (local-only)\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.summary.failed").Int(); got != 0 {
t.Fatalf("failed=%d want 0\nstdout:\n%s", got, out)
}
// Item-level checks.
assertSyncItem(t, out, "downloaded", "pull", "remote-only.txt", tokRemoteOnly)
assertSyncItem(t, out, "downloaded", "pull", "conflict.txt", tokConflict)
assertSyncItem(t, out, "uploaded", "push", "local-only.txt", "")
// Verify local file content after sync.
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict.txt"))
if err != nil {
t.Fatalf("read conflict.txt: %v", err)
}
if string(conflictContent) != "remote" {
t.Fatalf("conflict.txt content=%q want %q", string(conflictContent), "remote")
}
require.FileExists(t, filepath.Join(workDir, "local", "remote-only.txt"))
// Convergence: +status should now show all files as unchanged.
assertSyncConverges(t, ctx, workDir, folderToken, tokUnchanged)
})
// --- Subtest: local-wins ---
t.Run("local-wins pushes new_local and overwrites remote on conflict", func(t *testing.T) {
tokConflict := uploadDriveFile("conflict-lw.txt", "remote")
_ = uploadDriveFile("remote-only-lw.txt", "remote")
writeLocal("local/conflict-lw.txt", "local-wins")
writeLocal("local/local-only-lw.txt", "local")
writeLocal("local/remote-only-lw.txt", "already-here")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "local-wins",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Conflict file should be overwritten with local version.
assertSyncItem(t, out, "overwritten", "push", "conflict-lw.txt", tokConflict)
// Verify local content is unchanged (local-wins).
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-lw.txt"))
if err != nil {
t.Fatalf("read conflict-lw.txt: %v", err)
}
if string(conflictContent) != "local-wins" {
t.Fatalf("conflict-lw.txt content=%q want %q", string(conflictContent), "local-wins")
}
})
// --- Subtest: keep-both ---
t.Run("keep-both renames local and pulls remote", func(t *testing.T) {
uploadDriveFile("conflict-kb.txt", "remote-kb")
writeLocal("local/conflict-kb.txt", "local-kb")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "keep-both",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Should have a renamed_local item and a downloaded item.
assertSyncItem(t, out, "renamed_local", "conflict", "conflict-kb.txt", "")
assertSyncItem(t, out, "downloaded", "pull", "conflict-kb.txt", "")
// Original path now has remote content.
origContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-kb.txt"))
if err != nil {
t.Fatalf("read conflict-kb.txt: %v", err)
}
if string(origContent) != "remote-kb" {
t.Fatalf("conflict-kb.txt content=%q want %q", string(origContent), "remote-kb")
}
// A suffixed sibling should exist with the local content.
entries, err := os.ReadDir(filepath.Join(workDir, "local"))
if err != nil {
t.Fatalf("readdir local: %v", err)
}
var foundSuffixed bool
for _, e := range entries {
if strings.HasPrefix(e.Name(), "conflict-kb__lark_") && strings.HasSuffix(e.Name(), ".txt") {
foundSuffixed = true
suffixedContent, readErr := os.ReadFile(filepath.Join(workDir, "local", e.Name()))
if readErr != nil {
t.Fatalf("read suffixed file: %v", readErr)
}
if string(suffixedContent) != "local-kb" {
t.Fatalf("suffixed file content=%q want %q", string(suffixedContent), "local-kb")
}
break
}
}
if !foundSuffixed {
t.Fatalf("expected suffixed sibling conflict-kb__lark_*.txt, entries: %v", entries)
}
})
}
// TestDrive_SyncEmptyDirWorkflow proves that empty local directories are
// created on Drive during +sync, and that a subsequent +status converges.
func TestDrive_SyncEmptyDirWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-emptydir-"+suffix, "")
workDir := t.TempDir()
// Create an empty subdirectory under local.
if err := os.MkdirAll(filepath.Join(workDir, "local", "empty_sub"), 0o755); err != nil {
t.Fatalf("mkdir local/empty_sub: %v", err)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Should report folder_created for the empty directory.
if !strings.Contains(out, `"action": "folder_created"`) {
t.Fatalf("expected folder_created action for empty directory, got:\n%s", out)
}
if !strings.Contains(out, `"empty_sub"`) {
t.Fatalf("expected empty_sub in items, got:\n%s", out)
}
}
// assertSyncItem checks that a sync item with the given action, direction,
// and rel_path exists in the output. If fileToken is non-empty, it also
// verifies the item carries that token.
func assertSyncItem(t *testing.T, stdout, action, direction, relPath, fileToken string) {
t.Helper()
items := gjson.Get(stdout, "data.items")
if !items.IsArray() {
t.Fatalf("data.items is not an array\nstdout:\n%s", stdout)
}
var found bool
items.ForEach(func(_, item gjson.Result) bool {
if item.Get("action").String() != action || item.Get("direction").String() != direction || item.Get("rel_path").String() != relPath {
return true
}
found = true
if fileToken != "" {
if got := item.Get("file_token").String(); got != fileToken {
t.Errorf("item %s/%s/%s file_token=%q want %q", action, direction, relPath, got, fileToken)
}
}
return false
})
if !found {
t.Fatalf("missing sync item action=%s direction=%s rel_path=%s\nstdout:\n%s", action, direction, relPath, stdout)
}
}
// assertSyncConverges runs +status after a sync and asserts that all shared
// files are unchanged (i.e. the mirror has converged).
func assertSyncConverges(t *testing.T, ctx context.Context, workDir, folderToken, unchangedToken string) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
if got := gjson.Get(out, "data.modified.#").Int(); got != 0 {
t.Fatalf("post-sync +status modified=%d want 0\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.new_local.#").Int(); got != 0 {
t.Fatalf("post-sync +status new_local=%d want 0\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.new_remote.#").Int(); got != 0 {
t.Fatalf("post-sync +status new_remote=%d want 0\nstdout:\n%s", got, out)
}
if unchangedToken != "" {
assertStatusBucketEntry(t, out, "unchanged", "unchanged.txt", unchangedToken)
}
}
@@ -0,0 +1,98 @@
// 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/assert"
"github.com/stretchr/testify/require"
)
func TestDriveUploadDryRun_WikiTarget(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", "+upload",
"--file", "./report.pdf",
"--wiki-token", "wikcnDryRunUploadTarget",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, "parent_type")
assert.Contains(t, output, "parent_node")
assert.Contains(t, output, "wikcnDryRunUploadTarget")
assert.Contains(t, output, `"parent_type": "wiki"`)
}
func TestDriveUploadDryRun_WithFileToken(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", "+upload",
"--file", "./report.pdf",
"--folder-token", "fldDryRunUploadTarget",
"--file-token", "boxcnDryRunOverwriteTarget",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
assert.Contains(t, output, `"file_token": "boxcnDryRunOverwriteTarget"`)
}
func TestDriveUploadDryRunRejectsEmptyWikiToken(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", "+upload",
"--file", "./report.pdf",
"--wiki-token", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stderr, "--wiki-token cannot be empty")
}
func setDriveDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_UploadWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-upload-"+suffix, "")
workDir := t.TempDir()
cleanupTokens := map[string]struct{}{}
scheduleDelete := func(fileToken string) {
t.Helper()
if fileToken == "" {
return
}
if _, seen := cleanupTokens[fileToken]; seen {
return
}
cleanupTokens[fileToken] = struct{}{}
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
}
uploadFile := func(stageName, remoteName, content, fileToken string) string {
t.Helper()
stagePath := filepath.Join(workDir, stageName)
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
t.Fatalf("write stage file %s: %v", stageName, err)
}
args := []string{
"drive", "+upload",
"--file", stageName,
"--folder-token", folderToken,
"--name", remoteName,
}
if fileToken != "" {
args = append(args, "--file-token", fileToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
gotToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, gotToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
if got := gjson.Get(result.Stdout, "data.file_name").String(); got != remoteName {
t.Fatalf("data.file_name=%q want %q\nstdout:\n%s", got, remoteName, result.Stdout)
}
if got := gjson.Get(result.Stdout, "data.size").Int(); got != int64(len(content)) {
t.Fatalf("data.size=%d want %d\nstdout:\n%s", got, len(content), result.Stdout)
}
return gotToken
}
initialContent := "drive upload e2e: initial content\n"
initialToken := uploadFile("_upload_initial.txt", "overwrite.txt", initialContent, "")
scheduleDelete(initialToken)
updatedContent := "drive upload e2e: overwritten via file-token\n"
overwriteToken := uploadFile("_upload_overwrite.txt", "overwrite.txt", updatedContent, initialToken)
scheduleDelete(overwriteToken)
if overwriteToken != initialToken {
t.Fatalf("overwrite token=%q want original token=%q", overwriteToken, initialToken)
}
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+download",
"--file-token", overwriteToken,
"--output", "downloaded.txt",
"--overwrite",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
data, err := os.ReadFile(filepath.Join(workDir, "downloaded.txt"))
if err != nil {
t.Fatalf("read downloaded file: %v", err)
}
if string(data) != updatedContent {
t.Fatalf("downloaded content=%q want %q", string(data), updatedContent)
}
}
@@ -0,0 +1,229 @@
// 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/assert"
"github.com/stretchr/testify/require"
)
func TestDriveVersionHistoryDryRun(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", "+version-history",
"--file-token", "boxcnHistoryDryRun",
"--limit", "5",
"--cursor", "1777013761763",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnHistoryDryRun/history")
assert.Contains(t, output, `"only_tag": true`)
assert.Contains(t, output, `"page_size": 5`)
assert.Contains(t, output, `"last_edit_time": "1777013761763"`)
}
func TestDriveVersionGetDryRun(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", "+version-get",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--output", "./artifact.bin",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download")
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"output": "./artifact.bin"`)
}
func TestDriveVersionGetDryRunWithoutOutputUsesCurrentDirectory(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", "+version-get",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download")
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"output": "."`)
}
func TestDriveVersionRevertDryRun(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", "+version-revert",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/revert")
assert.Contains(t, output, `"version": "7633658129540910621"`)
}
func TestDriveVersionDeleteDryRun(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", "+version-delete",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/version_del")
assert.Contains(t, output, `"version": "7633658129540910621"`)
}
func TestDriveVersionDryRunSupportsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
setDriveDryRunConfigEnv(t)
tests := []struct {
name string
args []string
wantContains []string
}{
{
name: "history",
args: []string{
"drive", "+version-history",
"--file-token", "boxcnHistoryDryRunUser",
"--limit", "5",
"--cursor", "1777013761763",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnHistoryDryRunUser/history",
`"only_tag": true`,
`"page_size": 5`,
},
},
{
name: "get",
args: []string{
"drive", "+version-get",
"--file-token", "boxcnVersionDryRunUser",
"--version", "7633658129540910621",
"--output", "./artifact-user.bin",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/download",
`"version": "7633658129540910621"`,
`"output": "./artifact-user.bin"`,
},
},
{
name: "revert",
args: []string{
"drive", "+version-revert",
"--file-token", "boxcnVersionDryRunUser",
"--version", "7633658129540910621",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/revert",
`"version": "7633658129540910621"`,
},
},
{
name: "delete",
args: []string{
"drive", "+version-delete",
"--file-token", "boxcnVersionDryRunUser",
"--version", "7633658129540910621",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/version_del",
`"version": "7633658129540910621"`,
},
},
}
for _, tt := range tests {
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",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
for _, needle := range tt.wantContains {
assert.Contains(t, output, needle)
}
})
}
}
@@ -0,0 +1,234 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveVersionWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_VERSION_E2E") == "" {
t.Skip("set LARK_DRIVE_VERSION_E2E=1 to run drive version live workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-version-workflow-" + suffix + ".md"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# v1\n",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
clie2e.ReportCleanupFailure(parentT, "delete version workflow file "+fileToken, deleteResult, deleteErr)
})
overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", fileToken,
"--content", "# v2\n",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
overwriteResult.AssertExitCode(t, 0)
overwriteResult.AssertStdoutStatus(t, true)
overwriteResult, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", fileToken,
"--content", "# v3\n",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
overwriteResult.AssertExitCode(t, 0)
overwriteResult.AssertStdoutStatus(t, true)
historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
historyResult.AssertExitCode(t, 0)
historyResult.AssertStdoutStatus(t, true)
versions := gjson.Get(historyResult.Stdout, "data.versions").Array()
require.GreaterOrEqual(t, len(versions), 3, "stdout:\n%s", historyResult.Stdout)
var (
versionToDownload string
versionV1 string
versionV2 string
)
for _, version := range versions {
versionID := version.Get("version").String()
if versionID == "" {
continue
}
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-get",
"--file-token", fileToken,
"--version", versionID,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
WorkDir: downloadDir,
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
downloadedPath := filepath.Join(downloadDir, fileName)
body, err := os.ReadFile(downloadedPath)
require.NoError(t, err)
switch string(body) {
case "# v1\n":
versionV1 = versionID
case "# v2\n":
versionV2 = versionID
}
if versionToDownload == "" {
versionToDownload = versionID
}
}
require.NotEmpty(t, versionToDownload, "stdout:\n%s", historyResult.Stdout)
require.NotEmpty(t, versionV1, "stdout:\n%s", historyResult.Stdout)
require.NotEmpty(t, versionV2, "stdout:\n%s", historyResult.Stdout)
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-get",
"--file-token", fileToken,
"--version", versionToDownload,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
WorkDir: downloadDir,
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
downloadedPath := filepath.Join(downloadDir, fileName)
if _, err := os.Stat(downloadedPath); err != nil {
t.Fatalf("expected downloaded version at %q: %v", downloadedPath, err)
}
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-revert",
"--file-token", fileToken,
"--version", versionV1,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
revertResult.AssertExitCode(t, 0)
revertResult.AssertStdoutStatus(t, true)
fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
fetchResult.AssertExitCode(t, 0)
fetchResult.AssertStdoutStatus(t, true)
if got := gjson.Get(fetchResult.Stdout, "data.content").String(); got != "# v1\n" {
t.Fatalf("markdown content after revert = %q, want %q\nstdout:\n%s", got, "# v1\n", fetchResult.Stdout)
}
deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-delete",
"--file-token", fileToken,
"--version", versionV2,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
Yes: true,
})
require.NoError(t, err)
deleteResult.AssertExitCode(t, 0)
deleteResult.AssertStdoutStatus(t, true)
historyAfterDelete, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
historyAfterDelete.AssertExitCode(t, 0)
historyAfterDelete.AssertStdoutStatus(t, true)
foundDeletedVersion := false
for _, version := range gjson.Get(historyAfterDelete.Stdout, "data.versions").Array() {
if version.Get("version").String() != versionV2 {
continue
}
foundDeletedVersion = true
if !version.Get("is_deleted").Bool() {
t.Fatalf("version %s should be marked deleted after +version-delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout)
}
}
if !foundDeletedVersion {
t.Fatalf("deleted version %s not found in history after delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/tidwall/gjson"
)
const (
driveDeleteVisibilityTimeout = 30 * time.Second
driveDeleteVisibilityPoll = 3 * time.Second
)
var driveDeleteVisibilityWait = clie2e.WaitOptions{
// This wait only covers the post-delete visibility lag after Drive accepts
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
Timeout: driveDeleteVisibilityTimeout,
Interval: driveDeleteVisibilityPoll,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
t.Helper()
if defaultAs == "" {
defaultAs = "bot"
}
args := []string{"drive", "+create-folder", "--name", name}
if parentFolderToken != "" {
args = append(args, "--folder-token", parentFolderToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
})
if err != nil {
t.Fatalf("create drive folder %q: %v", name, err)
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
folderToken := gjson.Get(result.Stdout, "data.folder_token").String()
if folderToken == "" {
t.Fatalf("drive folder token should not be empty, stdout:\n%s", result.Stdout)
}
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, folderToken, "folder", defaultAs)
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
})
return folderToken
}
// DeleteDriveResourceAndVerify deletes a Drive-backed resource, then polls
// drive meta until the token is either gone or no longer has an accessible URL.
// This prevents cleanup from looking successful when the delete command
// returned a suppressed not_found or partial API error but the resource still
// exists.
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
}
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
if defaultAs == "" {
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}
if deleteResult.ExitCode != 0 {
deleted, verifyErr := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
if verifyErr != nil {
return deleteResult, verifyErr
}
if deleted {
deleteResult.ExitCode = 0
return deleteResult, nil
}
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
return deleteResult, clie2e.CleanupWarning(
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
)
}
return deleteResult, nil
}
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
opts.TimeoutError = func() error {
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
})
}
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {
if defaultAs == "" {
defaultAs = "bot"
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/drive/v1/metas/batch_query"},
DefaultAs: defaultAs,
Data: map[string]any{
"request_docs": []map[string]any{{
"doc_token": token,
"doc_type": docType,
}},
},
})
if err != nil {
return false, err
}
if result.ExitCode != 0 {
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combined, "not found") || strings.Contains(combined, "404") {
return true, nil
}
return false, fmt.Errorf("verify drive resource %s/%s after delete: exit=%d stdout=%s stderr=%s", docType, token, result.ExitCode, result.Stdout, result.Stderr)
}
if !isDriveMetaQuerySuccessful(result.Stdout) {
return false, fmt.Errorf("verify drive resource %s/%s after delete returned unsuccessful envelope: stdout=%s stderr=%s", docType, token, result.Stdout, result.Stderr)
}
metas := gjson.Get(result.Stdout, "data.metas").Array()
if len(metas) == 0 {
return true, nil
}
if docType != "folder" {
allURLsCleared := true
for _, meta := range metas {
if meta.Get("url").String() != "" {
allURLsCleared = false
break
}
}
if allURLsCleared {
return true, nil
}
}
return false, nil
}
func isDriveMetaQuerySuccessful(stdout string) bool {
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
return ok.Bool()
}
if code := gjson.Get(stdout, "code"); code.Exists() {
return code.Int() == 0
}
return false
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, parentFolderToken string) string {
t.Helper()
folderToken := CreateDriveFolder(t, parentT, ctx, name, "bot", parentFolderToken)
require.NotEmpty(t, folderToken)
return folderToken
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
Timeout: 10 * time.Millisecond,
Interval: time.Millisecond,
})
require.NotNil(t, result)
assert.Equal(t, 0, result.ExitCode)
require.Error(t, err)
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
})
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
require.NotNil(t, result)
assert.Equal(t, 1, result.ExitCode)
require.Error(t, err)
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
assert.Contains(t, err.Error(), "still exists after delete failed")
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi
echo "unexpected fake CLI args: $*" >&2
exit 2
`
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return binaryPath
}