package helpers import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // pull / push / sync 声明 Safety.Confirmation = user_required。本文件断言两件事: // 1. 未确认(无 --yes、非 dry-run)时命令必须在任何写操作之前拒绝执行; // 2. 明确确认(--yes)后才发出精确的工具调用并落盘。 // status 是只读叶子(not_required),不受确认门约束。 // ────────────────────────────────────────────────────────── // 1. 未确认即拒绝,且一个写操作都不发生 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageDriveSyncFamily_refusesWithoutConfirmation(t *testing.T) { cases := []struct { name string args []string setup func(t *testing.T, dir string) listJSON string }{ { name: "pull", args: []string{"pull"}, // 远端有文件、本地为空:即便是纯新增也必须先确认。 listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`, }, { name: "push", args: []string{"push"}, setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") }, listJSON: `{"result":{"items":[],"nextToken":""}}`, }, { name: "sync", args: []string{"sync", "--quick", "--on-conflict", "remote-wins"}, setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") }, listJSON: `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B","modifyTime":2}],"nextToken":""}}`, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() if tc.setup != nil { tc.setup(t, dir) } caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { switch tool { case "list_files": if nth > 0 { return `{"result":{"items":[],"nextToken":""}}`, nil } return tc.listJSON, nil case "download_file": return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil case "get_upload_info": return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil } return `{"result":{"fileId":"NEW"},"success":true}`, nil }} swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { t.Error("no download may happen before confirmation") return os.WriteFile(dest, []byte("leaked"), 0o644) }) testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { t.Error("no upload may happen before confirmation") return nil }) args := append(append([]string{}, tc.args...), "--local-folder", dir, "--remote-folder", "ROOT") err := runDriveCmdWithoutConfirm(t, caller, args...) if err == nil { t.Fatal("expected the command to refuse without --yes") } if !strings.Contains(err.Error(), "--yes") { t.Errorf("error must tell the user to confirm with --yes, got %q", err.Error()) } // 拒绝必须发生在任何写工具调用之前。 for _, tool := range []string{"download_file", "get_upload_info", "create_folder", "ln"} { if got := caller.callsFor(tool); len(got) != 0 { t.Errorf("%s must not be called before confirmation, got %v", tool, got) } } // 本地目录不得新增任何文件。 entries, _ := os.ReadDir(dir) for _, e := range entries { if e.Name() == "b.txt" || e.Name() == "a.txt" && tc.name == "pull" { t.Errorf("%s must not be materialized before confirmation", e.Name()) } } }) } } // --dry-run 无需 --yes 也能预览:这是让用户看清将发生什么的必要出口,且不写任何东西。 func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "l") caller := syncCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}],"nextToken":""}}`, }) caller.dryRun = true swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("dry-run must not download") return nil }) testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { t.Error("dry-run must not upload") return nil }) if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { t.Fatalf("dry-run must not require confirmation: %v", err) } if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) { t.Error("dry-run must not write remote files locally") } } func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) { parent := t.TempDir() target := filepath.Join(parent, "not-created") caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`) caller.dryRun = true swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("dry-run must not download") return nil }) var out bytes.Buffer testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"}) cmd := findDriveSubcommand(t, "pull") mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"}) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("dry-run pull: %v", err) } if _, err := os.Stat(target); !os.IsNotExist(err) { t.Fatalf("dry-run must not create local root: %v", err) } if got := len(caller.callsFor("download_file")); got != 0 { t.Fatalf("download_file calls = %d, want 0", got) } if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) { t.Fatalf("missing pull plan: %s", text) } } func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local") caller := syncCaller(nil) caller.dryRun = true testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { t.Error("dry-run must not upload") return nil }) var out bytes.Buffer testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"}) cmd := findDriveSubcommand(t, "push") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("dry-run push: %v", err) } for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} { if got := len(caller.callsFor(tool)); got != 0 { t.Fatalf("%s calls = %d, want 0", tool, got) } } if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) { t.Fatalf("missing push plan: %s", text) } } func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "local") caller := syncCaller(nil) caller.dryRun = true var out bytes.Buffer testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"}) cmd := findDriveSubcommand(t, "push") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("dry-run push: %v", err) } var payload map[string]any if err := json.Unmarshal(out.Bytes(), &payload); err != nil { t.Fatalf("decode dry-run push: %v\n%s", err, out.String()) } plan := payload["plan"].(map[string]any) summary := plan["summary"].(map[string]any) if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) { t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary) } items := plan["items"].([]any) if got := items[0].(map[string]any)["action"]; got != "planned_upload" { t.Fatalf("dry-run action = %v, want planned_upload", got) } } func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "local") mustWrite(t, filepath.Join(dir, "shared.txt"), "shared") if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil { t.Fatal(err) } caller := syncCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`, }) caller.dryRun = true var out bytes.Buffer testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"}) cmd := findDriveSubcommand(t, "sync") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("dry-run sync: %v", err) } var payload driveSyncDryRunResult if err := json.Unmarshal(out.Bytes(), &payload); err != nil { t.Fatalf("decode dry-run sync: %v\n%s", err, out.String()) } if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 { t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed) } if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 || payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 { t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary) } actions := map[string]int{} for _, item := range payload.Plan.Items { actions[item.Action]++ } if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 || actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 { t.Fatalf("dry-run action plan = %#v", payload.Plan.Items) } for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} { if calls := caller.callsFor(tool); len(calls) != 0 { t.Fatalf("dry-run called %s: %#v", tool, calls) } } } func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) { remoteFiles := map[string]*remoteFile{ "conflict.txt": {FileID: "remote-file"}, } remoteFolders := map[string]string{"": "root", "existing": "existing-id"} localDirs := []string{"existing", "new", "missing/child"} localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}} for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} { t.Run(policy, func(t *testing.T) { res := &driveSyncResult{} appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{ LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders, NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"}, Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy, }) if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 { t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items) } switch policy { case syncConflictRemoteWins, syncConflictKeepBoth: if res.Summary.PlannedPulls < 2 { t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls) } case syncConflictLocalWins: if res.Summary.PlannedPushes < 2 { t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes) } } }) } } func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "skip.txt"), "skip") mustWrite(t, filepath.Join(root, "smart.txt"), "smart") remote := map[string]*remoteFile{ "A.txt": {RelPath: "A.txt"}, "a.txt": {RelPath: "a.txt"}, "../escape": {RelPath: "../escape"}, "skip.txt": {RelPath: "skip.txt"}, "smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true}, "new.txt": {RelPath: "new.txt"}, } var out bytes.Buffer testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}}) if err := printDrivePullDryRun(root, ifExistsSkip, remote, []string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}, true); err != nil { t.Fatalf("skip plan: %v", err) } if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) { t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text) } out.Reset() if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}, false); err != nil { t.Fatalf("smart plan: %v", err) } if !strings.Contains(out.String(), pullActionSkipped) { t.Fatalf("smart plan must skip newer local file: %s", out.String()) } } func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) { var out bytes.Buffer testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}}) remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"} remoteFiles := map[string]*remoteFile{ "skip.txt": {RelPath: "skip.txt"}, "smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true}, "smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true}, "overwrite.txt": {RelPath: "overwrite.txt"}, } files := func(paths ...string) []localPushFile { result := make([]localPushFile, 0, len(paths)) for _, path := range paths { result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10}) } return result } if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders, []string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil { t.Fatalf("skip plan: %v", err) } if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) { t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text) } out.Reset() if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil, files("smart-skip.txt", "smart-overwrite.txt")); err != nil { t.Fatalf("smart plan: %v", err) } if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) { t.Fatalf("smart plan did not cover skip/overwrite: %s", text) } out.Reset() if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil { t.Fatalf("overwrite plan: %v", err) } if !strings.Contains(out.String(), pushActionPlannedOverwrite) { t.Fatalf("overwrite plan must overwrite existing file: %s", out.String()) } } // ────────────────────────────────────────────────────────── // 2. 明确确认后才发出精确的工具调用 // ────────────────────────────────────────────────────────── // sync --on-conflict remote-wins --yes:确认后只下载指定远端文件,且不上传。 func TestCrossPlatformCoverageDriveSync_confirmedRemoteWinsIssuesExactCalls(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-version") local := readFileMTimeMillis(t, p) withSyncTransport(t, "remote-version") caller := syncCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, }) if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick", "--on-conflict", "remote-wins", "--yes"); err != nil { t.Fatalf("unexpected error: %v", err) } dl := caller.callsFor("download_file") if len(dl) != 1 || dl[0].args["fileId"] != "F" { t.Fatalf("expected exactly one download_file(fileId=F), got %v", dl) } if up := caller.callsFor("get_upload_info"); len(up) != 0 { t.Errorf("remote-wins must not upload, got %v", up) } if b, _ := os.ReadFile(p); string(b) != "remote-version" { t.Errorf("remote-wins must replace the local content, got %q", string(b)) } } // pull --if-exists overwrite --yes:确认后才 download_file(fileId=A) 并覆盖本地内容。 func TestCrossPlatformCoverageDrivePull_confirmedOverwriteIssuesExactCalls(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.txt") mustWrite(t, p, "local-old") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`) swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("remote-new"), 0o644) }) if err := runDriveCmdWithoutConfirm(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil { t.Fatalf("unexpected error: %v", err) } dl := caller.callsFor("download_file") if len(dl) != 1 || dl[0].args["fileId"] != "A" { t.Fatalf("expected exactly one download_file(fileId=A), got %v", dl) } if b, _ := os.ReadFile(p); string(b) != "remote-new" { t.Errorf("confirmed overwrite must replace the local file, got %q", string(b)) } } // push --if-exists overwrite --yes:确认后原地覆盖,必须带 overwriteFileId 且不带 parentId。 func TestCrossPlatformCoverageDrivePush_confirmedOverwriteIssuesExactCalls(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "local") withNoopPut(t) caller := pushOKCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`, }) if err := runDriveCmdWithoutConfirm(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil { t.Fatalf("unexpected error: %v", err) } up := caller.callsFor("get_upload_info") if len(up) != 1 || up[0].args["overwriteFileId"] != "A" { t.Fatalf("expected get_upload_info(overwriteFileId=A), got %v", up) } if _, has := up[0].args["parentId"]; has { t.Error("in-place overwrite must not carry parentId") } } // sync --on-conflict local-wins --yes:确认后覆盖上传远端,且不下载。 func TestCrossPlatformCoverageDriveSync_confirmedLocalWinsIssuesExactCalls(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-version") local := readFileMTimeMillis(t, p) withSyncTransport(t, "unused") caller := syncCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, }) if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick", "--on-conflict", "local-wins", "--yes"); err != nil { t.Fatalf("unexpected error: %v", err) } up := caller.callsFor("get_upload_info") if len(up) != 1 || up[0].args["overwriteFileId"] != "F" { t.Fatalf("expected get_upload_info(overwriteFileId=F), got %v", up) } if dl := caller.callsFor("download_file"); len(dl) != 0 { t.Errorf("local-wins must not download, got %v", dl) } if b, _ := os.ReadFile(p); string(b) != "local-version" { t.Errorf("local-wins must keep the local content, got %q", string(b)) } } // ────────────────────────────────────────────────────────── // 3. 安全默认值本身 // ────────────────────────────────────────────────────────── // sync 默认 --on-conflict=skip:两侧都变更时两边内容都保留,且不发出任何传输调用。 func TestCrossPlatformCoverageDriveSync_defaultSkipsConflictsEntirely(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-version") local := readFileMTimeMillis(t, p) withSyncTransport(t, "remote-version") caller := syncCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, }) if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { t.Fatalf("unexpected error: %v", err) } if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 { t.Errorf("default skip must not transfer anything, got %d calls", got) } if b, _ := os.ReadFile(p); string(b) != "local-version" { t.Errorf("default skip must keep the local content, got %q", string(b)) } } // pull 默认 --if-exists=skip:本地已存在的文件不被覆盖。 func TestCrossPlatformCoverageDrivePull_defaultSkipsExistingLocalFiles(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.txt") mustWrite(t, p, "local-old") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`) swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("default skip must not download over an existing local file") return nil }) if err := runDriveCmd(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { t.Fatalf("unexpected error: %v", err) } if b, _ := os.ReadFile(p); string(b) != "local-old" { t.Errorf("default skip must keep the local file, got %q", string(b)) } }