From 0d20f884538622e626526e76df6ca537b2176fe9 Mon Sep 17 00:00:00 2001 From: wangweiming-01 Date: Thu, 14 May 2026 19:50:51 +0800 Subject: [PATCH] feat: support file-token overwrite and version output for drive +upload (#885) Change-Id: I76c334578fc2fa5cfd2eedb4525b0d9d735f610e --- shortcuts/drive/drive_io_test.go | 422 ++++++++++++++++++ .../drive/drive_permission_grant_test.go | 42 ++ shortcuts/drive/drive_upload.go | 119 +++-- .../references/lark-drive-upload.md | 18 +- tests/cli_e2e/drive/coverage.md | 5 +- .../cli_e2e/drive/drive_upload_dryrun_test.go | 25 ++ .../drive/drive_upload_workflow_test.go | 119 +++++ 7 files changed, 711 insertions(+), 39 deletions(-) create mode 100644 tests/cli_e2e/drive/drive_upload_workflow_test.go diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index eeee2576..01ee3c46 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -228,6 +228,206 @@ func TestDriveUploadLargeFileToWikiUsesMultipart(t *testing.T) { } } +func TestDriveUploadLargeFileOverwriteUsesMultipart(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-large-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + prepareStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_prepare", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "upload_id": "test-upload-id", + "block_size": float64(common.MaxDriveMediaUploadSinglePartSize), + "block_num": float64(2), + }, + }, + } + reg.Register(prepareStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_finish", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_multipart_overwrite_token", + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.bin") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "large.bin", + "--file-token", "box_existing_large_upload", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err) + } + + body := decodeCapturedJSONBody(t, prepareStub) + if got := body["file_token"]; got != "box_existing_large_upload" { + t.Fatalf("file_token = %#v, want %q", got, "box_existing_large_upload") + } +} + +func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinish(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-large-overwrite-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_prepare", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "upload_id": "test-upload-id", + "block_size": float64(common.MaxDriveMediaUploadSinglePartSize), + "block_num": float64(1), + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_finish", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_multipart_overwrite_version_token", + "version": "v44", + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.bin") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "large.bin", + "--file-token", "box_existing_large_upload", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got := data["version"]; got != "v44" { + t.Fatalf("data.version = %#v, want %q", got, "v44") + } +} + +func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinishAlias(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-large-overwrite-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_prepare", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "upload_id": "test-upload-id", + "block_size": float64(common.MaxDriveMediaUploadSinglePartSize), + "block_num": float64(1), + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_part", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_finish", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_multipart_overwrite_alias_token", + "data_version": "v45", + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.bin") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "large.bin", + "--file-token", "box_existing_large_upload", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got := data["version"]; got != "v45" { + t.Fatalf("data.version = %#v, want %q", got, "v45") + } +} + func TestDriveUploadSmallFile(t *testing.T) { uploadTestConfig := &core.CliConfig{ AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -267,6 +467,93 @@ func TestDriveUploadSmallFile(t *testing.T) { } } +func TestDriveUploadSmallFileOverwriteUsesFileToken(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-small-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_small_overwrite_token", + "version": "v42", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "small.bin", + "--file-token", "box_existing_small_upload", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected small overwrite upload to succeed, got error: %v", err) + } + + body := decodeDriveMultipartBody(t, stub) + if got := body.Fields["file_token"]; got != "box_existing_small_upload" { + t.Fatalf("file_token = %q, want %q", got, "box_existing_small_upload") + } + data := decodeDriveEnvelope(t, stdout) + if got := data["version"]; got != "v42" { + t.Fatalf("data.version = %#v, want %q", got, "v42") + } +} + +func TestDriveUploadReturnsVersionFromDataVersionAlias(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-small-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_small_alias_token", + "data_version": "v43", + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "small.bin", + "--file-token", "box_existing_alias_upload", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected overwrite upload to succeed, got error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got := data["version"]; got != "v43" { + t.Fatalf("data.version = %#v, want %q", got, "v43") + } +} + func TestDriveUploadSmallFileToWiki(t *testing.T) { uploadTestConfig := &core.CliConfig{ AppID: "drive-upload-small-wiki-test", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -767,6 +1054,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) { cmd := &cobra.Command{Use: "drive +upload"} cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") cmd.Flags().String("folder-token", "", "") cmd.Flags().String("wiki-token", "", "") cmd.Flags().String("name", "", "") @@ -812,6 +1100,7 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) { cmd := &cobra.Command{Use: "drive +upload"} cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") cmd.Flags().String("folder-token", "", "") cmd.Flags().String("wiki-token", "", "") cmd.Flags().String("name", "", "") @@ -821,6 +1110,9 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) { if err := cmd.Flags().Set("folder-token", " fld_upload_target "); err != nil { t.Fatalf("set --folder-token: %v", err) } + if err := cmd.Flags().Set("file-token", " box_upload_target "); err != nil { + t.Fatalf("set --file-token: %v", err) + } if err := cmd.Flags().Set("wiki-token", " wikcn_upload_target "); err != nil { t.Fatalf("set --wiki-token: %v", err) } @@ -839,11 +1131,108 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) { if got.FolderToken != "fld_upload_target" { t.Fatalf("FolderToken = %q, want trimmed token", got.FolderToken) } + if got.FileToken != "box_upload_target" { + t.Fatalf("FileToken = %q, want trimmed token", got.FileToken) + } if got.WikiToken != "wikcn_upload_target" { t.Fatalf("WikiToken = %q, want trimmed token", got.WikiToken) } } +func TestDriveUploadDryRunIncludesFileToken(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +upload"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("wiki-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "./report.pdf"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil { + t.Fatalf("set --file-token: %v", err) + } + + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveUpload.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 1 { + t.Fatalf("expected 1 API call, got %d", len(got.API)) + } + if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" { + t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite") + } +} + +func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +upload"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("wiki-token", "", "") + cmd.Flags().String("name", "", "") + cmd.Flags().String("as", "", "") + if err := cmd.Flags().Set("file", "./report.pdf"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil { + t.Fatalf("set --file-token: %v", err) + } + if err := cmd.Flags().Set("as", "bot"); err != nil { + t.Fatalf("set --as: %v", err) + } + + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveUpload.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Desc string `json:"desc"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 1 { + t.Fatalf("expected 1 API call, got %d", len(got.API)) + } + if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" { + t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite") + } + if strings.Contains(got.API[0].Desc, "grant the current CLI user full_access") { + t.Fatalf("dry-run desc should skip permission-grant hint for overwrite, got %q", got.API[0].Desc) + } +} + func TestDriveUploadTargetLabel(t *testing.T) { t.Parallel() @@ -901,6 +1290,7 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) { cmd := &cobra.Command{Use: "drive +upload"} cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") cmd.Flags().String("folder-token", "", "") cmd.Flags().String("wiki-token", "", "") cmd.Flags().String("name", "", "") @@ -923,6 +1313,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) { cmd := &cobra.Command{Use: "drive +upload"} cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") cmd.Flags().String("folder-token", "", "") cmd.Flags().String("wiki-token", "", "") cmd.Flags().String("name", "", "") @@ -940,11 +1331,35 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) { } } +func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +upload"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("wiki-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "report.pdf"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("file-token", " "); err != nil { + t.Fatalf("set --file-token: %v", err) + } + + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + err := DriveUpload.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") { + t.Fatalf("Validate() error = %v, want empty file-token error", err) + } +} + func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) { t.Parallel() cmd := &cobra.Command{Use: "drive +upload"} cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") cmd.Flags().String("folder-token", "", "") cmd.Flags().String("wiki-token", "", "") cmd.Flags().String("name", "", "") @@ -983,6 +1398,12 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) { value: "wikcn_bad#fragment", wantErr: "--wiki-token contains invalid characters", }, + { + name: "file token", + flag: "file-token", + value: "box_bad?query=true", + wantErr: "--file-token contains invalid characters", + }, } for _, tt := range tests { @@ -991,6 +1412,7 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) { cmd := &cobra.Command{Use: "drive +upload"} cmd.Flags().String("file", "", "") + cmd.Flags().String("file-token", "", "") cmd.Flags().String("folder-token", "", "") cmd.Flags().String("wiki-token", "", "") cmd.Flags().String("name", "", "") diff --git a/shortcuts/drive/drive_permission_grant_test.go b/shortcuts/drive/drive_permission_grant_test.go index 99056c68..20c0248b 100644 --- a/shortcuts/drive/drive_permission_grant_test.go +++ b/shortcuts/drive/drive_permission_grant_test.go @@ -75,6 +75,48 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) { } } +func TestDriveUploadBotOverwriteSkipsPermissionGrant(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + registerDriveBotTokenStub(reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_uploaded", + "version": "v2", + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("report.pdf", []byte("pdf"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "report.pdf", + "--file-token", "file_uploaded", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant for overwrite output: %#v", data) + } + if got := data["version"]; got != "v2" { + t.Fatalf("version = %#v, want %q", got, "v2") + } +} + func TestDriveImportBotAutoGrantSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) registerDriveBotTokenStub(reg) diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index 43747ca2..446ec400 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -27,6 +27,7 @@ const ( type driveUploadSpec struct { FilePath string + FileToken string FolderToken string WikiToken string Name string @@ -37,9 +38,15 @@ type driveUploadTarget struct { ParentNode string } +type driveUploadResult struct { + FileToken string + Version string +} + func newDriveUploadSpec(runtime *common.RuntimeContext) driveUploadSpec { return driveUploadSpec{ FilePath: runtime.Str("file"), + FileToken: strings.TrimSpace(runtime.Str("file-token")), FolderToken: strings.TrimSpace(runtime.Str("folder-token")), WikiToken: strings.TrimSpace(runtime.Str("wiki-token")), Name: runtime.Str("name"), @@ -89,6 +96,7 @@ var DriveUpload = common.Shortcut{ AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, + {Name: "file-token", Desc: "existing file token to overwrite in place"}, {Name: "folder-token", Desc: "target folder token (default: root folder; mutually exclusive with --wiki-token)"}, {Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"}, {Name: "name", Desc: "uploaded file name (default: local file name)"}, @@ -96,6 +104,8 @@ var DriveUpload = common.Shortcut{ Tips: []string{ "Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.", "Use --wiki-token to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.", + "Pass --file-token to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.", + "In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime)) @@ -103,22 +113,28 @@ var DriveUpload = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { spec := newDriveUploadSpec(runtime) target := spec.Target() + isOverwrite := spec.FileToken != "" + body := map[string]interface{}{ + "file_name": spec.FileName(), + "parent_type": target.ParentType, + "parent_node": target.ParentNode, + "file": "@" + spec.FilePath, + } + if spec.FileToken != "" { + body["file_token"] = spec.FileToken + } d := common.NewDryRunAPI(). Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)"). POST("/open-apis/drive/v1/files/upload_all"). - Body(map[string]interface{}{ - "file_name": spec.FileName(), - "parent_type": target.ParentType, - "parent_node": target.ParentNode, - "file": "@" + spec.FilePath, - }) - if runtime.IsBot() { + Body(body) + if runtime.IsBot() && !isOverwrite { d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.") } return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := newDriveUploadSpec(runtime) + isOverwrite := spec.FileToken != "" fileName := spec.FileName() target := spec.Target() @@ -130,32 +146,37 @@ var DriveUpload = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> %s\n", fileName, common.FormatSize(fileSize), target.Label()) - var fileToken string + var uploadResult driveUploadResult if fileSize > common.MaxDriveMediaUploadSinglePartSize { fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") - fileToken, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize) + uploadResult, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken) } else { - fileToken, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize) + uploadResult, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken) } if err != nil { return err } out := map[string]interface{}{ - "file_token": fileToken, + "file_token": uploadResult.FileToken, "file_name": fileName, "size": fileSize, } + if uploadResult.Version != "" { + out["version"] = uploadResult.Version + } // wiki-hosted files have no standalone /file/ URL — only the // wiki node URL, which the upload response doesn't carry. Skip the // fallback for parent_type=wiki rather than emit a link that 404s. if target.ParentType == driveUploadParentTypeExplorer { - if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" { + if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" { out["url"] = u } } - if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil { - out["permission_grant"] = grant + if !isOverwrite { + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil { + out["permission_grant"] = grant + } } runtime.Out(out, nil) @@ -164,6 +185,9 @@ var DriveUpload = common.Shortcut{ } func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error { + if driveUploadFlagExplicitlyEmpty(runtime, "file-token") { + return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite") + } if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") { return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token") } @@ -191,6 +215,11 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe return output.ErrValidation("%s", err) } } + if spec.FileToken != "" { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + } return nil } @@ -200,10 +229,10 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str strings.TrimSpace(runtime.Str(flagName)) == "" } -func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) { +func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) { f, err := runtime.FileIO().Open(filePath) if err != nil { - return "", common.WrapInputStatError(err) + return driveUploadResult{}, common.WrapInputStatError(err) } defer f.Close() @@ -213,6 +242,9 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file fd.AddField("parent_type", target.ParentType) fd.AddField("parent_node", target.ParentNode) fd.AddField("size", fmt.Sprintf("%d", fileSize)) + if existingFileToken != "" { + fd.AddField("file_token", existingFileToken) + } fd.AddFile("file", f) apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ @@ -223,34 +255,37 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file if err != nil { var exitErr *output.ExitError if errors.As(err, &exitErr) { - return "", err + return driveUploadResult{}, err } - return "", output.ErrNetwork("upload failed: %v", err) + return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err) } var result map[string]interface{} if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) } if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { msg, _ := result["msg"].(string) - return "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) } data, _ := result["data"].(map[string]interface{}) - fileToken, _ := data["file_token"].(string) + fileToken := common.GetString(data, "file_token") if fileToken == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") } - return fileToken, nil + return driveUploadResult{ + FileToken: fileToken, + Version: driveUploadVersionFromData(data), + }, nil } // uploadFileMultipart uploads a large file using the three-step multipart API: // 1. upload_prepare — get upload_id, block_size, block_num // 2. upload_part — upload each block sequentially -// 3. upload_finish — finalize and get file_token -func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) { +// 3. upload_finish — finalize and get file_token/version +func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) { // Step 1: Prepare prepareBody := map[string]interface{}{ "file_name": fileName, @@ -258,9 +293,12 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file "parent_node": target.ParentNode, "size": fileSize, } + if existingFileToken != "" { + prepareBody["file_token"] = existingFileToken + } prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) if err != nil { - return "", err + return driveUploadResult{}, err } uploadID := common.GetString(prepareResult, "upload_id") @@ -270,7 +308,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file blockNum := int(blockNumF) if uploadID == "" || blockSize <= 0 || blockNum <= 0 { - return "", output.Errorf(output.ExitAPI, "api_error", + return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", uploadID, blockSize, blockNum) } @@ -288,7 +326,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file partFile, err := runtime.FileIO().Open(filePath) if err != nil { - return "", common.WrapInputStatError(err) + return driveUploadResult{}, common.WrapInputStatError(err) } fd := larkcore.NewFormdata() @@ -306,18 +344,18 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file if err != nil { var exitErr *output.ExitError if errors.As(err, &exitErr) { - return "", err + return driveUploadResult{}, err } - return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err) + return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err) } var partResult map[string]interface{} if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil { - return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) + return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) } if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 { msg, _ := partResult["msg"].(string) - return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) + return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) } fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize)) @@ -330,13 +368,24 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file } finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody) if err != nil { - return "", err + return driveUploadResult{}, err } fileToken := common.GetString(finishResult, "file_token") if fileToken == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") + return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") } - return fileToken, nil + return driveUploadResult{ + FileToken: fileToken, + Version: driveUploadVersionFromData(finishResult), + }, nil +} + +func driveUploadVersionFromData(data map[string]interface{}) string { + version := common.GetString(data, "version") + if version == "" { + version = common.GetString(data, "data_version") + } + return version } diff --git a/skills/lark-drive/references/lark-drive-upload.md b/skills/lark-drive/references/lark-drive-upload.md index 68c4ec23..be94fb41 100644 --- a/skills/lark-drive/references/lark-drive-upload.md +++ b/skills/lark-drive/references/lark-drive-upload.md @@ -23,12 +23,16 @@ lark-cli drive +upload --file ./report.pdf # 自定义上传后的文件名 lark-cli drive +upload --file ./report.pdf --name "季度总结.pdf" +# 覆盖已存在文件(原地覆盖,保留 file_token) +lark-cli drive +upload --file ./report.pdf --file-token boxcn_existing_file + # 原生命令(高级/分片上传):预上传 + 完成上传 lark-cli drive files upload_prepare --data '{ "file_name": "report.pdf", "parent_type": "explorer", "parent_node": "fldbc_xxx", - "size": 1048576 + "size": 1048576, + "file_token": "boxcn_existing_file" }' lark-cli drive files upload_finish --data '{ "upload_id": "", @@ -40,7 +44,9 @@ lark-cli schema drive.files.upload_prepare ``` > [!IMPORTANT] -> 如果文件是**以应用身份(bot)上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。 +> 如果文件是**以应用身份(bot)新建上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。 +> +> 如果这次调用传了 `--file-token`,表示是在**覆盖已有文件**,CLI **不会**额外修改该文件权限。 > > 以应用身份上传时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: > - `status = granted`:当前 CLI 用户已获得该文件的可管理权限 @@ -51,12 +57,18 @@ lark-cli schema drive.files.upload_prepare > > **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 +> [!TIP] +> 当底层上传接口返回版本号时,shortcut 会在结果里额外透出 `version`。 + ## 目标位置选择(关键) - 上传到 Drive 文件夹:传 `--folder-token `,shortcut 会发送 `parent_type=explorer` - 上传到 wiki 节点:传 `--wiki-token `,shortcut 会发送 `parent_type=wiki` - 上传到 Drive 根目录:`--folder-token` 和 `--wiki-token` 都不传 +- 覆盖已有文件:额外传 `--file-token `;shortcut 会把它原样透传到底层 `upload_all` / `upload_prepare`,让后端按覆盖语义写入 +- bot 模式下,`--file-token` 覆盖只改文件内容;不会额外给当前 CLI 用户补 `full_access` - 不要传空目标值:`--folder-token ""` / `--wiki-token ""` 会被视为参数错误;如需上传到 Drive 根目录,应直接省略这两个参数 +- 不要传空 `--file-token`:如需新建上传,直接省略该参数;显式传空字符串会报错 - `--folder-token` 和 `--wiki-token` 互斥,不要同时传 - `--wiki-token` 传的是 **wiki node token**,不是 `space_id` @@ -65,6 +77,7 @@ Shortcut 参数: | 参数 | 必填 | 说明 | |------|------|------| | `--file` | 是 | 本地文件路径 | +| `--file-token` | 否 | 已存在文件的 token;传入后按“覆盖已有文件”语义上传 | | `--folder-token` | 否 | 目标文件夹 token;与 `--wiki-token` 互斥;省略时默认为 Drive 根目录;显式传空字符串会报错 | | `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;会映射为 `parent_type=wiki`、`parent_node=`;显式传空字符串会报错 | | `--name` | 否 | 上传后的文件名;默认使用本地文件名 | @@ -77,6 +90,7 @@ Shortcut 参数: | `parent_type` | 是 | 父节点类型;上传到文件夹 / 根目录时用 `"explorer"`,上传到 wiki 节点时用 `"wiki"` | | `parent_node` | 是 | 父节点 token;`explorer` 时传文件夹 token(根目录可为空字符串),`wiki` 时传 wiki node token | | `size` | 是 | 文件大小(字节) | +| `file_token` | 否 | 已存在文件 token;传入后覆盖该文件内容 | > [!CAUTION] > 这是**写入操作** —— 执行前必须确认用户意图。 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index c37c8e8e..e6c140ee 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -8,6 +8,7 @@ ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. - TestDrive_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. - TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs. @@ -15,7 +16,7 @@ - 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, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. -- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`; live duplicate/status workflows also use real `+upload` to seed remote fixtures. +- 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 @@ -33,7 +34,7 @@ | ✓ | 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 +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 +task_result | shortcut | | none | no async task-result workflow yet | -| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders | dry-run covers wiki-target shape; live workflows assert returned file tokens and consume the uploaded fixtures | +| ✓ | 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 | diff --git a/tests/cli_e2e/drive/drive_upload_dryrun_test.go b/tests/cli_e2e/drive/drive_upload_dryrun_test.go index 47af4449..66b339a4 100644 --- a/tests/cli_e2e/drive/drive_upload_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_upload_dryrun_test.go @@ -40,6 +40,31 @@ func TestDriveUploadDryRun_WikiTarget(t *testing.T) { 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, `"parent_node": "fldDryRunUploadTarget"`) + assert.Contains(t, output, `"file_token": "boxcnDryRunOverwriteTarget"`) +} + func TestDriveUploadDryRunRejectsEmptyWikiToken(t *testing.T) { setDriveDryRunConfigEnv(t) diff --git a/tests/cli_e2e/drive/drive_upload_workflow_test.go b/tests/cli_e2e/drive/drive_upload_workflow_test.go new file mode 100644 index 00000000..cf89cca0 --- /dev/null +++ b/tests/cli_e2e/drive/drive_upload_workflow_test.go @@ -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) + } +}