fix: harden destructive dry-run validation

This commit is contained in:
玉澜
2026-07-31 01:19:51 +08:00
parent f0d558a0d1
commit 461455b4fa
5 changed files with 136 additions and 24 deletions
+83 -3
View File
@@ -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 {
+25 -12
View File
@@ -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,
+10 -2
View File
@@ -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 体系,因此显式
+14 -6
View File
@@ -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()
}
+4 -1
View File
@@ -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,