feat: support file-token overwrite and version output for drive +upload (#885)

Change-Id: I76c334578fc2fa5cfd2eedb4525b0d9d735f610e
This commit is contained in:
wangweiming-01
2026-05-14 19:50:51 +08:00
committed by GitHub
parent b0bd9b0258
commit 0d20f88453
7 changed files with 711 additions and 39 deletions

View File

@@ -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", "", "")

View File

@@ -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)

View File

@@ -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 <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"Pass --file-token <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/<token> 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
}

View File

@@ -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": "<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 <folder_token>`shortcut 会发送 `parent_type=explorer`
- 上传到 wiki 节点:传 `--wiki-token <wiki_token>`shortcut 会发送 `parent_type=wiki`
- 上传到 Drive 根目录:`--folder-token``--wiki-token` 都不传
- 覆盖已有文件:额外传 `--file-token <existing_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=<wiki_token>`;显式传空字符串会报错 |
| `--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]
> 这是**写入操作** —— 执行前必须确认用户意图。

View File

@@ -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 |

View File

@@ -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)

View File

@@ -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)
}
}