Files
dingtalk-workspace-cli/internal/helpers/destructive_confirmation_test.go

507 lines
17 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"context"
stderrors "errors"
"io"
"os"
"reflect"
"sort"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type guardedMutationCall struct {
productID string
toolName string
args map[string]any
}
type guardedMutationCaller struct {
calls []guardedMutationCall
dryRun bool
}
func (c *guardedMutationCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
}
func (*guardedMutationCaller) Format() string { return "json" }
func (c *guardedMutationCaller) DryRun() bool { return c.dryRun }
func (*guardedMutationCaller) Fields() string { return "" }
func (*guardedMutationCaller) JQ() string { return "" }
func executeGuardedMutationCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
root := build()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
// Closed stdin makes ConfirmSafety return typed confirmation_required
// instead of hanging on an interactive prompt — but do not clobber a
// fixture that already SetIn("no\n") / similar decline input.
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
func requireTypedConfirmationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("command without --yes unexpectedly succeeded")
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("confirmation error = %T %v, want typed validation error", err, err)
}
if appErr.Reason != "confirmation_required" {
t.Fatalf("confirmation reason = %q, want confirmation_required", appErr.Reason)
}
}
func TestChatChmodRequiresConfirmationBeforeRemoteCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"chmod", "chat.message:send",
"--agentCode", "wukong",
"--grant-type", "timed",
"--ttl", "24h",
"--conversation-id", "cidX",
)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
}
}
func TestChatChmodCallsRemoteWithExactArgsWhenConfirmed(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"chmod", "chat.message:send",
"--agentCode", "wukong",
"--grant-type", "timed",
"--ttl", "24h",
"--conversation-id", "cidX",
"--yes",
)
if err != nil {
t.Fatalf("chat chmod with --yes returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.productID != "im" || call.toolName != "chat_permission_grant" {
t.Fatalf("tool call = %s/%s, want im/chat_permission_grant", call.productID, call.toolName)
}
wantArgs := map[string]any{
"agentCode": "wukong",
"scope": "chat.message:send",
"grantType": "timed",
"ttl": "24h",
"grantParams": `{"conversationId":"cidX","openCid":"cidX","openConversationId":"cidX"}`,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
type guardedStepCaller struct {
calls []guardedMutationCall
steps []string
index int
dryRun bool
}
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
text := `{}`
if c.index < len(c.steps) {
text = c.steps[c.index]
}
c.index++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*guardedStepCaller) Format() string { return "json" }
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
func (*guardedStepCaller) Fields() string { return "" }
func (*guardedStepCaller) JQ() string { return "" }
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
baseArgs := []string{
"message", "share-to-chat",
"--email", "user@company.com",
"--id", "msg-1",
"--users", "uid1",
}
caller := &guardedMutationCaller{}
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
stepCaller := &guardedStepCaller{steps: []string{
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
`{"ok":true}`,
}}
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed share with sign returned error: %v", err)
}
if len(stepCaller.calls) != 2 {
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
}
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
}
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
t.Fatalf("second call sign = %q, want sig", sign)
}
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed direct success returned error: %v", err)
}
if len(directCaller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
}
if directCaller.calls[0].toolName != "share_message_to_chat" {
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
}
dryRunCaller := &guardedMutationCaller{dryRun: true}
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
if err != nil {
t.Fatalf("dry-run without --yes returned error: %v", err)
}
if len(dryRunCaller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
}
}
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "dismiss", "--group", "conversation-1")
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "dismiss", "--group", "conversation-1", "--yes")
if err != nil {
t.Fatalf("confirmed dismiss returned error: %v", err)
}
want := guardedMutationCall{
productID: "im",
toolName: "dismiss_group",
args: map[string]any{"openConversationId": "conversation-1"},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
}
func TestSheetClearRangeRequiresConfirmationBeforeToolCall(t *testing.T) {
args := []string{
"range", "clear",
"--node", "node-1",
"--sheet-id", "sheet-1",
"--range", "A1:B3",
"--type", "all",
}
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newSheetCommand, append(args, "--yes")...)
if err != nil {
t.Fatalf("confirmed clear returned error: %v", err)
}
want := guardedMutationCall{
productID: "",
toolName: "clear_range",
args: map[string]any{
"nodeId": "node-1",
"sheetId": "sheet-1",
"range": "A1:B3",
"type": "all",
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
caller = &guardedMutationCaller{dryRun: true}
err = executeGuardedMutationCommand(t, caller, newSheetCommand, append(args, "--dry-run")...)
if err != nil {
t.Fatalf("clear dry-run without --yes returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
}
func TestSheetBatchClearRequiresConfirmationBeforeToolCall(t *testing.T) {
args := []string{
"range", "batch-clear",
"--node", "node-1",
"--ranges", `["Sheet1!A1:B3","Sheet2!C1:D5"]`,
"--type", "all",
}
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newSheetCommand, append(args, "--yes")...)
if err != nil {
t.Fatalf("confirmed batch-clear returned error: %v", err)
}
want := guardedMutationCall{
productID: "",
toolName: "batch_update",
args: map[string]any{
"nodeId": "node-1",
"operations": []any{
map[string]any{"toolName": "clear_range", "input": map[string]any{"sheetId": "Sheet1", "range": "A1:B3", "type": "all"}},
map[string]any{"toolName": "clear_range", "input": map[string]any{"sheetId": "Sheet2", "range": "C1:D5", "type": "all"}},
},
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
caller = &guardedMutationCaller{dryRun: true}
err = executeGuardedMutationCommand(t, caller, newSheetCommand, append(args, "--dry-run")...)
if err != nil {
t.Fatalf("batch-clear dry-run without --yes returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
}
func TestSheetBatchUpdateRequiresConfirmationBeforeToolCall(t *testing.T) {
operations := `[{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},{"toolName":"delete-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","position":2,"length":3}}]`
args := []string{"batch-update", "--node", "node-1", "--operations", operations}
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newSheetCommand, append(args, "--yes")...)
if err != nil {
t.Fatalf("confirmed batch-update returned error: %v", err)
}
want := guardedMutationCall{
productID: "",
toolName: "batch_update",
args: map[string]any{
"nodeId": "node-1",
"operations": []any{
map[string]any{"toolName": "clear_range", "input": map[string]any{"sheetId": "Sheet1", "range": "A1:B3", "type": "content"}},
map[string]any{"toolName": "delete_dimension", "input": map[string]any{"sheetId": "Sheet1", "dimension": "ROWS", "startIndex": 2, "count": 3}},
},
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
caller = &guardedMutationCaller{dryRun: true}
err = executeGuardedMutationCommand(t, caller, newSheetCommand, append(args, "--dry-run")...)
if err != nil {
t.Fatalf("batch-update dry-run without --yes returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
}
func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
tests := []struct {
path string
args []string
}{
{
path: "sheet batch-update",
args: []string{"batch-update", "--node", "node-1", "--operations", `[{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3"}}]`},
},
{
path: "sheet chart delete",
args: []string{"chart", "delete", "--node", "node-1", "--sheet-id", "sheet-1", "--chart-id", "chart-1"},
},
{
path: "sheet range clear",
args: []string{"range", "clear", "--node", "node-1", "--sheet-id", "sheet-1", "--range", "A1:B3"},
},
{
path: "sheet cond-format delete",
args: []string{"cond-format", "delete", "--node", "node-1", "--sheet-id", "sheet-1", "--rule-id", "rule-1"},
},
{
path: "sheet delete-dimension",
args: []string{"delete-dimension", "--node", "node-1", "--sheet-id", "sheet-1", "--dimension", "ROWS", "--position", "1", "--length", "1"},
},
{
path: "sheet delete-dropdown",
args: []string{"delete-dropdown", "--node", "node-1", "--sheet-id", "sheet-1", "--range", "A1:A2"},
},
{
path: "sheet filter delete",
args: []string{"filter", "delete", "--node", "node-1", "--sheet-id", "sheet-1"},
},
{
path: "sheet filter-view delete",
args: []string{"filter-view", "delete", "--node", "node-1", "--sheet-id", "sheet-1", "--filter-view-id", "view-1"},
},
{
path: "sheet delete-float-image",
args: []string{"delete-float-image", "--node", "node-1", "--sheet-id", "sheet-1", "--float-image-id", "image-1"},
},
{
path: "sheet pivot-table delete",
args: []string{"pivot-table", "delete", "--node", "node-1", "--sheet-id", "sheet-1", "--pivot-table-id", "pivot-1"},
},
{
path: "sheet delete-sheet",
args: []string{"delete-sheet", "--node", "node-1", "--sheet-id", "sheet-1"},
},
{
path: "sheet filter-view delete-criteria",
args: []string{"filter-view", "delete-criteria", "--node", "node-1", "--sheet-id", "sheet-1", "--filter-view-id", "view-1", "--column", "0"},
},
{
path: "sheet range batch-clear",
args: []string{"range", "batch-clear", "--node", "node-1", "--ranges", `["Sheet1!A1:B3"]`},
},
{
path: "sheet range move-to",
args: []string{"range", "move-to", "--node", "node-1", "--sheet-id", "sheet-1", "--source-range", "A1:B3", "--target-range", "D1"},
},
{
path: "sheet comment delete",
args: []string{"comment", "delete", "--node", "node-1", "--comment-key", "ck-1"},
},
{
path: "sheet version revert",
args: []string{"version", "revert", "--node", "node-1", "--version", "2"},
},
}
// A newly protected command must add a runnable case here. This keeps the
// runtime assertion coupled to the structural wrapper instead of a stale
// hand-maintained subset.
root := newSheetCommand()
guardedPaths := make(map[string]bool)
var visit func(*cobra.Command)
visit = func(command *cobra.Command) {
if HasSheetMutationConfirmationGuard(command) {
guardedPaths[command.CommandPath()] = true
}
for _, child := range command.Commands() {
visit(child)
}
}
visit(root)
testPaths := make(map[string]bool, len(tests))
for _, test := range tests {
testPaths[test.path] = true
}
var missingCases, staleCases []string
for path := range guardedPaths {
if !testPaths[path] {
missingCases = append(missingCases, path)
}
}
for path := range testPaths {
if !guardedPaths[path] {
staleCases = append(staleCases, path)
}
}
if len(missingCases) != 0 || len(staleCases) != 0 {
sort.Strings(missingCases)
sort.Strings(staleCases)
t.Fatalf("Sheet confirmation runtime cases differ from protected leaves: missing_cases=%v stale_cases=%v", missingCases, staleCases)
}
for _, test := range tests {
test := test
t.Run(strings.TrimPrefix(test.path, "sheet "), func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, test.args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
}
caller = &guardedMutationCaller{dryRun: true}
dryRunArgs := append(append([]string(nil), test.args...), "--dry-run")
if err := executeGuardedMutationCommand(t, caller, newSheetCommand, dryRunArgs...); err != nil {
t.Fatalf("dry-run without --yes returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
})
}
}