507 lines
17 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|