fix: harden destructive dry-run validation
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 体系,因此显式
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user