diff --git a/internal/helpers/contract_defects_test.go b/internal/helpers/contract_defects_test.go index bc0e3466..d8d516c9 100644 --- a/internal/helpers/contract_defects_test.go +++ b/internal/helpers/contract_defects_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -19,6 +20,7 @@ import ( type contractDefectCaller struct { dryRun bool calls []guardedMutationCall + readCalls []guardedMutationCall responses map[string]string errors map[string]error } @@ -36,6 +38,19 @@ func (c *contractDefectCaller) CallTool(_ context.Context, productID, toolName s return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } +func (c *contractDefectCaller) CallReadTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.readCalls = append(c.readCalls, guardedMutationCall{productID: productID, toolName: toolName, args: args}) + key := productID + "/" + toolName + if err := c.errors[key]; err != nil { + return nil, err + } + text := c.responses[key] + if text == "" { + text = `{}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + func (*contractDefectCaller) Format() string { return "json" } func (c *contractDefectCaller) DryRun() bool { return c.dryRun } func (*contractDefectCaller) Fields() string { return "" } @@ -60,6 +75,7 @@ func executeContractDefectCommand(t *testing.T, caller *contractDefectCaller, bu } root.SilenceErrors = true root.SilenceUsage = true + root.SetIn(strings.NewReader("")) root.SetArgs(args) err := root.Execute() return stdout.String(), err @@ -81,15 +97,25 @@ func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) { } } -func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) { - caller := &contractDefectCaller{dryRun: true} +func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) { + caller := &contractDefectCaller{ + dryRun: true, + responses: map[string]string{ + "doc/list_doc_versions": `{"versions":[{"version":7}]}`, + }, + } output, err := executeContractDefectCommand(t, caller, newDocCommand, "version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run") if err != nil { t.Fatalf("doc version revert dry-run returned error: %v", err) } if len(caller.calls) != 0 { - t.Fatalf("dry-run tool calls = %#v, want none", caller.calls) + t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls) + } + if len(caller.readCalls) != 1 || + caller.readCalls[0].productID != "doc" || + caller.readCalls[0].toolName != "list_doc_versions" { + t.Fatalf("dry-run read calls = %#v, want doc/list_doc_versions", caller.readCalls) } if !strings.Contains(output, `"tool": "revert_doc_version"`) || !strings.Contains(output, `"version": 7`) { @@ -97,6 +123,60 @@ func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) } } +func TestDocVersionRevertDryRunRejectsMissingVersionBeforePreview(t *testing.T) { + caller := &contractDefectCaller{ + dryRun: true, + responses: map[string]string{ + "doc/list_doc_versions": `{"versions":[{"version":7}]}`, + }, + } + output, err := executeContractDefectCommand(t, caller, newDocCommand, + "version", "revert", "--node", "node-dry-run", "--version", "999", "--dry-run") + if err == nil || !strings.Contains(err.Error(), "文档版本 999 不存在") { + t.Fatalf("missing version error = %v, want explicit rejection", err) + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Reason != "version_not_found" { + t.Fatalf("missing version error = %#v, want typed version_not_found", err) + } + if len(caller.readCalls) != 1 { + t.Fatalf("dry-run read calls = %#v, want one version lookup", caller.readCalls) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls) + } + if strings.Contains(output, `"tool": "revert_doc_version"`) { + t.Fatalf("missing version emitted a misleading mutation preview: %q", output) + } +} + +func TestDriveDeleteDryRunSkipsConfirmationAndEOFIsObservable(t *testing.T) { + caller := &contractDefectCaller{dryRun: true} + output, err := executeContractDefectCommand(t, caller, newDriveCommand, + "delete", "--node", "node-dry-run", "--dry-run") + if err != nil { + t.Fatalf("drive delete dry-run returned error: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls) + } + if !strings.Contains(output, `"tool": "delete_document"`) || + !strings.Contains(output, `"nodeId": "node-dry-run"`) { + t.Fatalf("dry-run output = %q, want delete preview", output) + } + + caller = &contractDefectCaller{} + _, err = executeContractDefectCommand(t, caller, newDriveCommand, + "delete", "--node", "node-eof") + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("closed-stdin error = %#v, want typed confirmation_required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("closed-stdin mutation calls = %#v, want none", caller.calls) + } +} + func TestDocRenamePreservesCallerProvidedDisplayName(t *testing.T) { renameCmd, remaining, err := newDocCommand().Find([]string{"rename"}) if err != nil || len(remaining) != 0 { diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index ab2c0ee3..7b398ee1 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -14,6 +14,8 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -42,7 +44,7 @@ func docVersionExists(ctx context.Context, nodeID string, version int) (bool, er if cursor != "" { toolArgs["nextCursor"] = cursor } - text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_doc_versions", toolArgs) + text, err := callMCPReadToolReturnTextOnServer(ctx, "doc", "list_doc_versions", toolArgs) if err != nil { return false, err } @@ -2707,17 +2709,28 @@ CLI 内部自动完成全部流程: return fmt.Errorf("flag --version is required") } version, _ := cmd.Flags().GetInt("version") - if !commandDryRun(cmd) { - exists, err := docVersionExists(cmd.Context(), nodeID, version) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("文档版本 %d 不存在,已停止回滚;请先执行 dws doc version list --node %s --format json 获取可回滚版本", version, nodeID) - } - if !confirmDangerousAction(cmd, fmt.Sprintf("revert document to version %d", version), nodeID) { - return nil - } + exists, err := docVersionExists(cmd.Context(), nodeID, version) + if err != nil { + return err + } + if !exists { + return apperrors.NewValidation( + fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version), + apperrors.WithReason("version_not_found"), + apperrors.WithHint(fmt.Sprintf( + "请先执行 dws doc version list --node %s --format json 获取可回滚版本", + nodeID, + )), + apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"), + ) + } + if err := cmdcore.ConfirmSafety(cmd, cli.SafetySpec{ + Effect: "destructive", + Risk: "high", + Confirmation: "user_required", + Idempotency: "unknown", + }); err != nil { + return err } return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{ "nodeId": nodeID, diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 87211ed1..f6257af6 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -11,6 +11,9 @@ import ( "time" "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore" ) // ────────────────────────────────────────────────────────── @@ -960,8 +963,13 @@ func newDriveCommand() *cobra.Command { if fileID == "" { return fmt.Errorf("flag --node is required") } - if !confirmDelete("钉盘节点", fileID) { - return nil + if err := cmdcore.ConfirmSafety(cmd, cli.SafetySpec{ + Effect: "destructive", + Risk: "high", + Confirmation: "user_required", + Idempotency: "unknown", + }); err != nil { + return err } // 同 dws doc delete:delete_document 工具仅注册在 doc MCP server 上, // 钉盘节点(fileId)与文档节点共用同一套 dentryUuid 体系,因此显式 diff --git a/internal/helpers/drive_coverage_more_test.go b/internal/helpers/drive_coverage_more_test.go index 71ed1d00..a7905d3a 100644 --- a/internal/helpers/drive_coverage_more_test.go +++ b/internal/helpers/drive_coverage_more_test.go @@ -193,10 +193,13 @@ func TestCrossPlatformCoverageDriveDownloadDirectoryCoverage(t *testing.T) { func TestCrossPlatformCoverageDriveConfirmationCancellationCoverage(t *testing.T) { oldStdin := os.Stdin t.Cleanup(func() { os.Stdin = oldStdin }) - for _, args := range [][]string{ - {"delete", "--node", "node"}, - {"publish", "set", "--node", "node"}, - {"publish", "unset", "--node", "node"}, + for _, test := range []struct { + args []string + wantObservableCancel bool + }{ + {args: []string{"delete", "--node", "node"}, wantObservableCancel: true}, + {args: []string{"publish", "set", "--node", "node"}}, + {args: []string{"publish", "unset", "--node", "node"}}, } { file, err := os.CreateTemp(t.TempDir(), "stdin") if err != nil { @@ -207,8 +210,13 @@ func TestCrossPlatformCoverageDriveConfirmationCancellationCoverage(t *testing.T } _, _ = file.Seek(0, 0) os.Stdin = file - if err := executeDriveEdge(t, &scriptedToolCaller{}, args...); err != nil { - t.Fatalf("cancel %v: %v", args, err) + err = executeDriveEdge(t, &scriptedToolCaller{}, test.args...) + if test.wantObservableCancel { + if err == nil || !strings.Contains(err.Error(), "用户取消了操作") { + t.Fatalf("cancel %v = %v, want observable cancellation error", test.args, err) + } + } else if err != nil { + t.Fatalf("cancel %v: %v", test.args, err) } _ = file.Close() } diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 4daa30f2..de8ef989 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -155,7 +155,10 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin // ReadToolCaller capability; if unavailable, it fails closed instead of // returning a synthetic dry-run envelope that looks like business data. func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) { - ctx := context.Background() + return callMCPReadToolReturnTextOnServer(context.Background(), serverID, toolName, args) +} + +func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) { if !IsReadToolName(toolName) { return "", &CLIError{ Code: CodeMCPToolError,