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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion shortcuts/drive/drive_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ var DriveImport = common.Shortcut{
"docs:document.media:upload",
"docs:document:import",
},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
Expand Down Expand Up @@ -61,6 +62,7 @@ var DriveImport = common.Shortcut{
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")

appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)

dry.POST("/open-apis/drive/v1/import_tasks").
Expand All @@ -87,6 +89,9 @@ var DriveImport = common.Shortcut{
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}

// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
Expand Down
40 changes: 40 additions & 0 deletions shortcuts/drive/drive_import_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,46 @@
return nil
}

func appendDriveImportFolderTokenWikiCheckDryRun(dry *common.DryRunAPI, spec driveImportSpec) {
folderToken := strings.TrimSpace(spec.FolderToken)
if folderToken == "" {
return
}

dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[0] Validate whether --folder-token is a wiki node").
Params(map[string]interface{}{"token": folderToken})
}

func rejectDriveImportWikiFolderToken(runtime *common.RuntimeContext, folderToken string) error {
folderToken = strings.TrimSpace(folderToken)
if folderToken == "" {
return nil
}

data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": folderToken},
nil,
)
if err == nil {
node := common.GetMap(data, "node")
if len(node) == 0 {
return nil

Check warning on line 286 in shortcuts/drive/drive_import_common.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_import_common.go#L286

Added line #L286 was not covered by tests
}

return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node",
).
WithParam("--folder-token").
WithHint("Pass a Drive folder token, or omit --folder-token to import into the Drive root folder. Wiki node tokens are not accepted as import mount folders.")
}

return nil
}

// driveImportStatus captures the backend fields needed to decide whether the
// import can be surfaced immediately or requires a follow-up poll.
type driveImportStatus struct {
Expand Down
130 changes: 130 additions & 0 deletions shortcuts/drive/drive_import_common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ package drive

import (
"bytes"
"errors"
"os"
"strings"
"testing"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
Expand Down Expand Up @@ -276,6 +278,134 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}

func TestDriveImportRejectsWikiFolderToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"node_token": "wikcnImportTarget",
"obj_type": "docx",
"obj_token": "docxImportTarget",
"title": "Wiki Import Target",
},
},
},
})

tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}

err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "wikcnImportTarget",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected wiki folder-token validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != "--folder-token" {
t.Fatalf("param = %q, want --folder-token", validationErr.Param)
}
wantMessage := "--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node"
if validationErr.Message != wantMessage {
t.Fatalf("message = %q, want %q", validationErr.Message, wantMessage)
}
for _, disallowed := range []string{"node_token=", "obj_type=", "Wiki Import Target"} {
if strings.Contains(validationErr.Message, disallowed) {
t.Fatalf("message = %q, must not contain %q", validationErr.Message, disallowed)
}
}
if !strings.Contains(validationErr.Hint, "Drive folder token") {
t.Fatalf("hint = %q, want Drive folder token guidance", validationErr.Hint)
}
}

func TestDriveImportContinuesWhenFolderTokenDoesNotResolveAsWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 1310001,
"msg": "node not found",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_import_media"},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_import_folder"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import_folder",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "docx",
"job_status": 0,
"token": "docx_imported",
},
},
},
})

tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}

err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "fldcnImportTarget",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

data := decodeDriveEnvelope(t, stdout)
if got := data["token"]; got != "docx_imported" {
t.Fatalf("token = %#v, want docx_imported", got)
}
body := decodeCapturedJSONBody(t, createStub)
point, _ := body["point"].(map[string]interface{})
if got := point["mount_key"]; got != "fldcnImportTarget" {
t.Fatalf("import mount_key = %#v, want fldcnImportTarget", got)
}
}

func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())

Expand Down
12 changes: 8 additions & 4 deletions shortcuts/drive/drive_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,20 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
}

if got.API[0].Body != nil {
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
}

uploadName, _ := got.API[0].Body["file_name"].(string)
uploadName, _ := got.API[1].Body["file_name"].(string)
if uploadName != "base-import.xlsx" {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}

importName, _ := got.API[1].Body["file_name"].(string)
importName, _ := got.API[2].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}
Expand Down
58 changes: 58 additions & 0 deletions tests/cli_e2e/drive/drive_import_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading