Files
dingtalk-workspace-cli/internal/pat/chmod_test.go
玉澜andCursor b64438d01c fix(corecmd): keep Validate and ConfirmSafety on the same RunE layer
PreRunE Validate was skipped by direct RunE / proxy calls. Run both hooks
in one wrapper (Validate first), add pat chmod Validate, let Sheet outer
guards call ContractValidate first, and assert declare user_required
leaves expose Validate, required flags, or CallTool-defer confirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:11 +08:00

2075 lines
76 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package pat
import (
"bytes"
"context"
"errors"
"io"
"os"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds
// into the outgoing batch request.
type fakeToolCaller struct {
mu sync.Mutex
dryRun bool
gotTool string
gotArgs map[string]any
gotAgentEnv string
gotSessionEnv string
gotDingSessionEnv string
callN int
resultOK bool
}
func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.callN++
f.gotTool = toolName
f.gotAgentEnv = os.Getenv(agentCodeEnv)
f.gotSessionEnv = os.Getenv(sessionIDEnvDWS)
f.gotDingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
// defensive copy — RunE / runApply may mutate the map after return
f.gotArgs = make(map[string]any, len(args))
for k, v := range args {
f.gotArgs[k] = v
}
// Empty success payload keeps handleToolResult / emitApplyResult happy
// without triggering PAT classification in errors.ClassifyMCPResponseText.
if f.resultOK {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{}}`}}}, nil
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fakeToolCaller) Format() string { return "json" }
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
func (f *fakeToolCaller) Fields() string { return "" }
func (f *fakeToolCaller) JQ() string { return "" }
type recordedToolCall struct {
tool string
args map[string]any
agentEnv string
sessionEnv string
dingSessionEnv string
}
type fallbackToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
if len(f.calls) == 1 {
return &edition.ToolResult{}, nil
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fallbackToolCaller) Format() string { return "json" }
func (f *fallbackToolCaller) DryRun() bool { return false }
func (f *fallbackToolCaller) Fields() string { return "" }
func (f *fallbackToolCaller) JQ() string { return "" }
type fallbackErrorToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
if len(f.calls) == 1 {
return nil, errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fallbackErrorToolCaller) Format() string { return "json" }
func (f *fallbackErrorToolCaller) DryRun() bool { return false }
type fallbackSchemaMismatchToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackSchemaMismatchToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
if len(f.calls) == 1 {
return nil, apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PARAM_ERROR",
TechnicalDetail: `input schema validation failed: unknown field "scopes"; missing required field "scope"`,
}),
)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"data":{"authRequestId":"req-ok"}}`}}}, nil
}
func (f *fallbackSchemaMismatchToolCaller) Format() string { return "json" }
func (f *fallbackSchemaMismatchToolCaller) DryRun() bool { return false }
type fallbackPermissionDeniedToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackPermissionDeniedToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
return nil, apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_MEDIUM_RISK_NO_PERMISSION",
TechnicalDetail: "permission denied for scope chat.message:send",
}),
)
}
func (f *fallbackPermissionDeniedToolCaller) Format() string { return "json" }
func (f *fallbackPermissionDeniedToolCaller) DryRun() bool { return false }
type fallbackPATErrorToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackPATErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
return nil, &apperrors.PATError{RawJSON: `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}`}
}
func (f *fallbackPATErrorToolCaller) Format() string { return "json" }
func (f *fallbackPATErrorToolCaller) DryRun() bool { return false }
type fallbackPATContractErrorToolCaller struct {
calls []recordedToolCall
}
func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
f.calls = append(f.calls, recordedToolCall{tool: toolName, args: copied})
return nil, apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_SCOPE_AUTH_REQUIRED",
TechnicalDetail: `missingScope mail:send`,
}),
)
}
func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" }
func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
func (f *fallbackPATContractErrorToolCaller) Fields() string { return "" }
func (f *fallbackPATContractErrorToolCaller) JQ() string { return "" }
type sequenceToolCaller struct {
calls []recordedToolCall
responses []string
errs []error
dryRun bool
}
func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for k, v := range args {
copied[k] = v
}
s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied})
s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv)
s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS)
s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil {
return nil, s.errs[len(s.calls)-1]
}
response := `{"success":true,"data":{}}`
if len(s.responses) >= len(s.calls) {
response = s.responses[len(s.calls)-1]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func (s *sequenceToolCaller) Format() string { return "json" }
func (s *sequenceToolCaller) DryRun() bool { return s.dryRun }
func (s *sequenceToolCaller) Fields() string { return "" }
func (s *sequenceToolCaller) JQ() string { return "" }
func stringSliceArgEqual(got any, want []string) bool {
gotSlice, ok := got.([]string)
if !ok || len(gotSlice) != len(want) {
return false
}
for i := range want {
if gotSlice[i] != want[i] {
return false
}
}
return true
}
func captureStdout(t *testing.T, fn func() error) (string, error) {
t.Helper()
oldStdout := os.Stdout
readPipe, writePipe, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe error = %v", err)
}
os.Stdout = writePipe
runErr := fn()
_ = writePipe.Close()
os.Stdout = oldStdout
out, readErr := io.ReadAll(readPipe)
_ = readPipe.Close()
if readErr != nil {
t.Fatalf("ReadAll stdout error = %v", readErr)
}
return string(out), runErr
}
// buildChmod returns a freshly constructed chmod cobra.Command wired to
// fake. Using the factory (instead of a package-level var) keeps every
// subtest hermetic and matches the upstream shared-state fix in PR #129.
func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
t.Helper()
return chmodWithYes(t, newChmodCommand(fake))
}
// chmodWithYes attaches root persistent --yes for Catalog user_required
// ConfirmSafety when tests call RunE directly.
func chmodWithYes(t *testing.T, cmd *cobra.Command) *cobra.Command {
t.Helper()
attachRootYesFlag(t, cmd, true)
return cmd
}
func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "skip confirmation")
root.AddCommand(cmd)
if yes {
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatalf("set root --yes: %v", err)
}
}
}
func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) {
t.Helper()
attachRootPATFlagsOpts(t, cmd, yes, false, formatChanged)
}
func attachRootPATFlagsOpts(t *testing.T, cmd *cobra.Command, yes, dryRun, formatChanged bool) {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "skip confirmation")
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
root.PersistentFlags().String("format", "json", "")
root.PersistentFlags().Bool("verbose", false, "")
root.AddCommand(cmd)
if yes {
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatalf("set root --yes: %v", err)
}
}
if dryRun {
if err := root.PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatalf("set root --dry-run: %v", err)
}
}
if formatChanged {
if err := root.PersistentFlags().Set("format", "json"); err != nil {
t.Fatalf("set root --format: %v", err)
}
}
}
func setBatchYesForTest(t *testing.T, cmd *cobra.Command) {
t.Helper()
attachRootYesFlag(t, cmd, true)
}
func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
root := &cobra.Command{Use: "dws"}
RegisterCommands(root, &fakeToolCaller{})
patCmd, _, err := root.Find([]string{"pat"})
if err != nil {
t.Fatalf("pat command not found: %v", err)
}
children := map[string]bool{}
for _, child := range patCmd.Commands() {
children[child.Name()] = true
}
if !children["chmod"] {
t.Fatal("pat chmod command not registered")
}
for _, unexpected := range []string{"grant-batch", "grant-recommend", "scopes", "check"} {
if children[unexpected] {
t.Fatalf("unexpected pat subcommand %q registered; authorization must stay on chmod", unexpected)
}
}
}
func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
root := &cobra.Command{Use: "dws"}
RegisterCommands(root, &fakeToolCaller{})
patCmd, _, err := root.Find([]string{"pat"})
if err != nil {
t.Fatalf("pat command not found: %v", err)
}
var out strings.Builder
patCmd.SetOut(&out)
patCmd.SetErr(&out)
if err := patCmd.Help(); err != nil {
t.Fatalf("pat help error = %v", err)
}
patHelp := out.String()
for _, want := range []string{
"支持批量授权",
"--products / --product",
"--domains / --domain",
"--recommend",
"DINGTALK_DWS_AGENTCODE",
"未传 agentCode 时由服务端默认兜底",
} {
if !strings.Contains(patHelp, want) {
t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp)
}
}
chmodCmd, _, err := root.Find([]string{"pat", "chmod"})
if err != nil {
t.Fatalf("pat chmod command not found: %v", err)
}
out.Reset()
chmodCmd.SetOut(&out)
chmodCmd.SetErr(&out)
if err := chmodCmd.Help(); err != nil {
t.Fatalf("pat chmod help error = %v", err)
}
chmodHelp := out.String()
for _, want := range []string{
"批量授权:",
"一次传多个 scope",
"batch plan",
"--dry-run 只返回授权计划",
"执行批量授权必须显式",
"由服务端默认兜底",
"aitable.record:query aitable.record:create --grant-type permanent --yes",
"dws pat chmod --products calendar,aitable",
"dws pat chmod --recommend --yes",
} {
if !strings.Contains(chmodHelp, want) {
t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
}
}
}
func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`,
`{"success":true,"data":{"grantedScopes":["calendar.event:read","aitable.record:read"]}}`,
}}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
setBatchYesForTest(t, cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar", "aitable"}) {
t.Fatalf("productCodes = %#v, want calendar/aitable", got)
}
if got := fake.calls[0].args["recommend"]; got != false {
t.Fatalf("recommend = %#v, want false", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("plan agent env = %q, want qoderwork", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("batch plan agentCode = %#v, want qoderwork", got)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("grant agent env = %q, want qoderwork", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("batch grant agentCode = %#v, want qoderwork", got)
}
}
func TestChmod_productsFlagWithoutYesBlocksAfterPlan(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
}}
cmd := newChmodCommand(fake)
attachRootYesFlag(t, cmd, false)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar")
err := cmd.RunE(cmd, nil)
if err == nil {
t.Fatal("chmod RunE error = nil, want confirmation gate")
}
// Catalog user_required ConfirmSafety runs after Validate and before RunE,
// so the plan MCP call is not reached without --yes.
if !strings.Contains(err.Error(), "--yes") ||
!(strings.Contains(err.Error(), "confirmation_required") ||
strings.Contains(err.Error(), "需要用户确认") ||
strings.Contains(err.Error(), "batch PAT authorization blocked")) {
t.Fatalf("error = %q, want --yes confirmation gate", err.Error())
}
if len(fake.calls) != 0 {
t.Fatalf("CallTool count = %d, want none before confirmation", len(fake.calls))
}
}
func TestChmod_multipleScopesWithoutYesBlocksBeforeMCP(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{}
cmd := newChmodCommand(fake)
attachRootYesFlag(t, cmd, false)
_ = cmd.Flags().Set("grant-type", "permanent")
err := cmd.RunE(cmd, []string{"calendar.event:read", "aitable.record:write"})
if err == nil {
t.Fatal("chmod RunE error = nil, want confirmation gate")
}
if !strings.Contains(err.Error(), "--yes") ||
!(strings.Contains(err.Error(), "confirmation_required") ||
strings.Contains(err.Error(), "需要用户确认") ||
strings.Contains(err.Error(), "batch PAT authorization blocked")) {
t.Fatalf("error = %q, want --yes confirmation gate", err.Error())
}
if len(fake.calls) != 0 {
t.Fatalf("CallTool count = %d, want blocker before MCP", len(fake.calls))
}
}
func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
}}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "session")
_ = cmd.Flags().Set("products", "calendar")
_ = cmd.Flags().Set("session-id", "session-123")
setBatchYesForTest(t, cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if got := fake.calls[0].args["grantType"]; got != "session" {
t.Fatalf("plan grantType = %#v, want session", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
t.Fatalf("plan sessionId = %#v, want session-123", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("plan agent env = %q, want qoderwork", got)
}
if fake.calls[0].dingSessionEnv != "session-123" {
t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
}
if got := fake.calls[1].args["grantType"]; got != "session" {
t.Fatalf("grant grantType = %#v, want session", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
t.Fatalf("grant sessionId = %#v, want session-123", got)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("grant agent env = %q, want qoderwork", got)
}
if fake.calls[1].dingSessionEnv != "session-123" {
t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv)
}
}
func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) {
t.Setenv(agentCodeEnv, "")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
attachRootPATFlags(t, cmd, true, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, []string{"contact.user:get-self"})
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
if _, ok := fake.calls[0].args["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args)
}
if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") {
t.Fatalf("summary output missing server default agentCode:\n%s", output)
}
}
func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) {
t.Setenv(agentCodeEnv, "envshouldlose")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("agentCode", "qoderwork")
attachRootPATFlags(t, cmd, true, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, []string{"chat.bot:search"})
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got)
}
if !strings.Contains(output, "agentCode: qoderwork") {
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
}
}
func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) {
cases := []struct {
name string
args []string
setFlags func(*cobra.Command)
wantPlanProducts []string
wantRecommend bool
wantCallCount int
}{
{
name: "direct multi scope",
args: []string{"calendar.event:list", "calendar.event:create"},
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
},
wantCallCount: 1,
},
{
name: "product repeated",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("product", "calendar")
_ = cmd.Flags().Set("product", "aitable")
},
wantPlanProducts: []string{"calendar", "aitable"},
wantCallCount: 2,
},
{
name: "products comma list",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
},
wantPlanProducts: []string{"calendar", "aitable"},
wantCallCount: 2,
},
{
name: "domain repeated",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("domain", "calendar")
_ = cmd.Flags().Set("domain", "chat")
},
wantPlanProducts: []string{"calendar", "chat"},
wantCallCount: 2,
},
{
name: "domains comma list",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("domains", "calendar,chat")
},
wantPlanProducts: []string{"calendar", "chat"},
wantCallCount: 2,
},
{
name: "recommend",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
},
wantRecommend: true,
wantCallCount: 2,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
responses := []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
}
if tc.wantCallCount == 2 {
responses = []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`,
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
}
}
fake := &sequenceToolCaller{responses: responses}
cmd := chmodWithYes(t, newChmodCommand(fake))
tc.setFlags(cmd)
attachRootPATFlags(t, cmd, true, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, tc.args)
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != tc.wantCallCount {
t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount)
}
if tc.wantCallCount == 1 {
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) {
t.Fatalf("grant scopes = %#v, want %#v", got, tc.args)
}
} else {
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
}
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:list", "calendar.event:create"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
}
last := fake.calls[len(fake.calls)-1]
if got := last.args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
}
if !strings.Contains(output, "agentCode: qoderwork") {
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
}
})
}
}
func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) {
cases := []struct {
name string
setFlags func(*cobra.Command)
wantPlanProducts []string
wantRecommend bool
}{
{
name: "product",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("products", "calendar,aitable")
},
wantPlanProducts: []string{"calendar", "aitable"},
},
{
name: "domain",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("domains", "calendar,chat")
},
wantPlanProducts: []string{"calendar", "chat"},
},
{
name: "recommend",
setFlags: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("recommend", "true")
},
wantRecommend: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{
dryRun: true,
responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`,
},
}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
tc.setFlags(cmd)
attachRootPATFlagsOpts(t, cmd, false, true, false)
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, nil)
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
}
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
}
if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") {
t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output)
}
})
}
}
func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
cases := []struct {
name string
grantType string
sessionFlag string
sessionEnv string
wantSessionID string
wantErr string
}{
{name: "once no session", grantType: "once"},
{name: "permanent no session", grantType: "permanent"},
{name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"},
{name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"},
{name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"},
{name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
if tc.sessionEnv != "" {
t.Setenv(sessionIDEnvDWS, tc.sessionEnv)
}
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`,
}}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", tc.grantType)
if tc.sessionFlag != "" {
_ = cmd.Flags().Set("session-id", tc.sessionFlag)
}
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr)
}
if len(fake.calls) != 0 {
t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls))
}
return
}
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if got := fake.calls[0].args["grantType"]; got != tc.grantType {
t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType)
}
if tc.wantSessionID == "" {
if _, ok := fake.calls[0].args["sessionId"]; ok {
t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args)
}
return
}
if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID {
t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID)
}
if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID {
t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID)
}
})
}
}
func TestChmod_productsSessionDryRunUsesSessionIDFromEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDWS, "env-session-123")
fake := &sequenceToolCaller{
dryRun: true,
responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
},
}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "session")
_ = cmd.Flags().Set("products", "calendar")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
}
if got := fake.calls[0].agentEnv; got != "qoderwork" {
t.Fatalf("plan agent env = %q, want qoderwork", got)
}
if fake.calls[0].dingSessionEnv != "env-session-123" {
t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
}
}
func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{
errs: []error{
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: patForgedIdentityCode,
}),
),
nil,
},
responses: []string{
"",
`{"success":true,"data":{"agentCode":"qoderwork","allGranted":true,"selectedScopes":[]}}`,
},
}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar")
setBatchYesForTest(t, cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName {
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("first plan agentCode = %#v, want qoderwork", got)
}
if _, ok := fake.calls[1].args["agentCode"]; ok {
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
}
}
func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{
errs: []error{
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: patForgedIdentityCode,
}),
),
nil,
},
responses: []string{
"",
`{"success":true,"data":{"agentCode":"qoderwork","grantedScopes":["calendar.event:read"]}}`,
},
}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("first grant agentCode = %#v, want qoderwork", got)
}
if _, ok := fake.calls[1].args["agentCode"]; ok {
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
}
if got := fake.calls[1].agentEnv; got != "qoderwork" {
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
}
}
func TestChmod_batchGrantIdentityFallbackRejectsMismatchedAgentCode(t *testing.T) {
t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend")
fake := &sequenceToolCaller{
errs: []error{
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: patForgedIdentityCode,
}),
),
nil,
},
responses: []string{
"",
`{"success":true,"result":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`,
},
}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "permanent")
err := cmd.RunE(cmd, []string{"chat.message:send"})
if err == nil {
t.Fatal("chmod RunE error = nil, want identity fallback agentCode mismatch")
}
if !strings.Contains(err.Error(), "identity fallback returned agentCode") ||
!strings.Contains(err.Error(), "dingmbw5n9ktkkbbjv3g") ||
!strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") {
t.Fatalf("error = %q, want fallback mismatch details", err.Error())
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if _, ok := fake.calls[1].args["agentCode"]; ok {
t.Fatalf("compat retry should still omit agentCode arg, got %#v", fake.calls[1].args)
}
if got := fake.calls[1].agentEnv; got != "dinglqdkz3mmw2xwvend" {
t.Fatalf("compat retry %s = %q, want requested agentCode", agentCodeEnv, got)
}
}
func TestChmod_batchGrantIdentityFallbackRejectsMissingAgentCode(t *testing.T) {
t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend")
fake := &sequenceToolCaller{
errs: []error{
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: patForgedIdentityCode,
}),
),
nil,
},
responses: []string{
"",
`{"success":true,"data":{"grantedScopes":["chat.message:send"]}}`,
},
}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "permanent")
err := cmd.RunE(cmd, []string{"chat.message:send"})
if err == nil {
t.Fatal("chmod RunE error = nil, want unverifiable fallback error")
}
if !strings.Contains(err.Error(), "authorization target cannot be verified") {
t.Fatalf("error = %q, want unverifiable fallback details", err.Error())
}
}
func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
t.Setenv(sessionIDEnvDingtalk, "ding-session")
t.Setenv(sessionIDEnvDWS, "dws-session")
t.Setenv(sessionIDEnvRewind, "rewind-session")
if got := resolveSessionIDFromEnv(); got != "ding-session" {
t.Fatalf("resolveSessionIDFromEnv() = %q, want DINGTALK_SESSION_ID", got)
}
t.Setenv(sessionIDEnvDingtalk, "")
if got := resolveSessionIDFromEnv(); got != "dws-session" {
t.Fatalf("resolveSessionIDFromEnv() = %q, want DWS_SESSION_ID", got)
}
t.Setenv(sessionIDEnvDWS, "")
if got := resolveSessionIDFromEnv(); got != "rewind-session" {
t.Fatalf("resolveSessionIDFromEnv() = %q, want REWIND_SESSION_ID", got)
}
}
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "session")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
}
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
}
if fake.gotDingSessionEnv != "ding-session-123" {
t.Fatalf("%s during CallTool = %q, want ding-session-123", sessionIDEnvDingtalk, fake.gotDingSessionEnv)
}
if fake.gotSessionEnv != "ding-session-123" {
t.Fatalf("%s during CallTool = %q, want ding-session-123", sessionIDEnvDWS, fake.gotSessionEnv)
}
}
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "stale-session")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "session")
_ = cmd.Flags().Set("session-id", "flag-session")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
}
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
t.Fatalf("sessionId arg = %#v, want flag-session", got)
}
if fake.gotDingSessionEnv != "flag-session" {
t.Fatalf("%s during CallTool = %q, want flag-session", sessionIDEnvDingtalk, fake.gotDingSessionEnv)
}
if fake.gotSessionEnv != "flag-session" {
t.Fatalf("%s during CallTool = %q, want flag-session", sessionIDEnvDWS, fake.gotSessionEnv)
}
}
func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["recommended.scope:read"]}}`,
`{"success":true,"data":{"grantedScopes":["recommended.scope:read"]}}`,
}}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("recommend", "true")
setBatchYesForTest(t, cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["recommend"]; got != true {
t.Fatalf("recommend = %#v, want true", got)
}
if got := fake.calls[0].args["grantType"]; got != grantTypePermanent {
t.Fatalf("plan grantType = %#v, want %q", got, grantTypePermanent)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["grantType"]; got != grantTypePermanent {
t.Fatalf("grant grantType = %#v, want %q", got, grantTypePermanent)
}
}
func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing.T) {
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历","displayName":"日程查看","operationSummary":"日历管理、日程创建"},{"scope":"doc.document:read","productCode":"doc","productName":"文档","displayName":"文档读取"}],"selectedScopes":["calendar.event:read","doc.document:read"]}}`,
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历","displayName":"日程查看"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`,
}}
var selectorProducts []LoginRecommendProduct
err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{
ProductSelector: func(products []LoginRecommendProduct) ([]string, error) {
selectorProducts = products
return []string{"calendar"}, nil
},
})
if err != nil {
t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err)
}
if len(selectorProducts) != 2 {
t.Fatalf("selector products = %d, want 2", len(selectorProducts))
}
if selectorProducts[0].ProductCode != "calendar" || selectorProducts[0].ProductName != "日历" {
t.Fatalf("first selector product = %+v, want calendar/日历", selectorProducts[0])
}
if selectorProducts[0].Summary != "日历管理、日程创建" {
t.Fatalf("first selector summary = %q", selectorProducts[0].Summary)
}
if len(fake.calls) != 3 {
t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) {
t.Fatalf("initial plan productCodes = %#v, want empty", got)
}
if got := fake.calls[0].args["recommend"]; got != true {
t.Fatalf("initial plan recommend = %#v, want true", got)
}
if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) {
t.Fatalf("selected plan productCodes = %#v, want calendar", got)
}
if got := fake.calls[1].args["recommend"]; got != true {
t.Fatalf("selected plan recommend = %#v, want true", got)
}
if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend {
t.Fatalf("selected plan caller = %#v, want %q", got, patCallerAuthLoginRecommend)
}
if fake.calls[2].tool != patBatchGrantToolName {
t.Fatalf("third tool = %q, want %q", fake.calls[2].tool, patBatchGrantToolName)
}
if got := fake.calls[2].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read"}) {
t.Fatalf("grant scopes = %#v, want selected calendar scope", got)
}
}
func TestLoginRecommendAuthorizationWithoutSelectorKeepsSinglePlan(t *testing.T) {
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`,
}}
if err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{}); err != nil {
t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want one plan + grant", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) {
t.Fatalf("plan productCodes = %#v, want empty", got)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["startFlow"]; got != true {
t.Fatalf("startFlow = %#v, want true for unconfirmed recommend login", got)
}
if got := fake.calls[1].args["noWait"]; got != true {
t.Fatalf("noWait = %#v, want true for unconfirmed recommend login", got)
}
}
func TestLoginRecommendAuthorizationRecommendedAlreadyGrantedSkipsSelectorAndGrant(t *testing.T) {
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`,
}}
var out bytes.Buffer
err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, &out, LoginRecommendOptions{
ScopeMode: LoginRecommendScopeRecommended,
ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
t.Fatal("already-granted recommended plan must not ask for product selection")
return nil, nil
},
})
if err != nil {
t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want only initial recommend plan", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("tool sequence = %q, want only %q", fake.calls[0].tool, patBatchPlanToolName)
}
if strings.Contains(out.String(), "flowId") {
t.Fatalf("output = %q, must not include authorization flow", out.String())
}
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
t.Fatalf("output = %q, want already-granted message", out.String())
}
}
func TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes(t *testing.T) {
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`,
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`,
`{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`,
}}
err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{
ScopeMode: LoginRecommendScopeAll,
ProductSelector: func(products []LoginRecommendProduct) ([]string, error) {
return []string{"calendar"}, nil
},
})
if err != nil {
t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err)
}
if len(fake.calls) != 3 {
t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.calls))
}
if got := fake.calls[0].args["recommend"]; got != true {
t.Fatalf("initial discovery plan recommend = %#v, want true before product selection", got)
}
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) {
t.Fatalf("initial discovery plan productCodes = %#v, want empty", got)
}
if got := fake.calls[1].args["recommend"]; got != false {
t.Fatalf("selected plan recommend = %#v, want false for all scope mode", got)
}
if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) {
t.Fatalf("selected plan productCodes = %#v, want calendar", got)
}
if got := fake.calls[2].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "calendar.event:write"}) {
t.Fatalf("grant scopes = %#v, want all selected calendar scopes", got)
}
}
func TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan(t *testing.T) {
fake := &sequenceToolCaller{}
err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{
ScopeMode: LoginRecommendScopeAll,
})
if err == nil {
t.Fatal("RunLoginRecommendAuthorizationWithOptions error = nil, want product-domain validation error")
}
if !strings.Contains(err.Error(), "至少一个授权业务域") {
t.Fatalf("error = %v, want product-domain validation error", err)
}
if len(fake.calls) != 0 {
t.Fatalf("CallTool count = %d, want no empty all-scope plan call", len(fake.calls))
}
}
func TestLoginRecommendAuthorizationConfirmedGrantsDirectly(t *testing.T) {
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
}}
if err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{Confirmed: true}); err != nil {
t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool count = %d, want one plan + grant", len(fake.calls))
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[0].args["recommend"]; got != true {
t.Fatalf("plan recommend = %#v, want true for confirmed recommend login", got)
}
if _, ok := fake.calls[1].args["startFlow"]; ok {
t.Fatalf("startFlow should be omitted for confirmed recommend login")
}
if _, ok := fake.calls[1].args["noWait"]; ok {
t.Fatalf("noWait should be omitted for confirmed recommend login")
}
if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend {
t.Fatalf("caller = %#v, want %q", got, patCallerAuthLoginRecommend)
}
}
func TestChmod_productsAllGrantedStopsAfterPlan(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
}}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar")
setBatchYesForTest(t, cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want only plan call", len(fake.calls))
}
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
}
func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &fakeToolCaller{dryRun: true}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "once")
output, err := captureStdout(t, func() error {
return cmd.RunE(cmd, []string{"aitable.record:read"})
})
if err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if !strings.Contains(output, patBatchGrantToolName) {
t.Fatalf("dry-run output = %q, want %q", output, patBatchGrantToolName)
}
if strings.Contains(output, patGrantToolName+"\n") {
t.Fatalf("dry-run output = %q, must not advertise legacy %q", output, patGrantToolName)
}
}
// ---------------------------------------------------------------------------
// T1 · Agent-code env fallback tests
// ---------------------------------------------------------------------------
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
// the env value up for both batch arguments and gateway-compatible env.
func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
// grant-type=once → no session-id needed; keeps the test hermetic.
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v (must not report flag missing)", err)
}
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if got := fake.gotAgentEnv; got != "qoderwork" {
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
}
if _, ok := fake.gotArgs["scope"]; ok {
t.Fatalf("unexpected legacy singular scope arg in argv: %#v", fake.gotArgs)
}
}
func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got)
}
}
func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) {
t.Setenv(agentCodeEnv, "")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
}
if fake.callN != 1 {
t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN)
}
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got)
}
}
func TestChmod_agentCode_envServerMismatchFails(t *testing.T) {
t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkkbjv3g","grantType":"permanent","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`,
}}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("grant-type", "permanent")
err := cmd.RunE(cmd, []string{"chat.message:send"})
if err == nil {
t.Fatal("chmod RunE error = nil, want agentCode mismatch error")
}
if !strings.Contains(err.Error(), "dingmbw5n9ktkkkbjv3g") ||
!strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") {
t.Fatalf("error = %q, want both returned and expected agentCode", err.Error())
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
}
if got := fake.calls[0].args["agentCode"]; got != "dinglqdkz3mmw2xwvend" {
t.Fatalf("batch grant agentCode = %#v, want DINGTALK_DWS_AGENTCODE", got)
}
if got := fake.calls[0].agentEnv; got != "dinglqdkz3mmw2xwvend" {
t.Fatalf("%s during CallTool = %q, want DINGTALK_DWS_AGENTCODE", agentCodeEnv, got)
}
}
func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"aitable.record:read"},
"grantType": "permanent",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"aitable.record:read"},
"grantType": "permanent",
}
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err != nil {
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
}
if !isEmptyToolResult(result) {
t.Fatalf("expected original empty canonical result, got %#v", result)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
if _, ok := fake.calls[0].args["scopes"]; !ok {
t.Fatalf("canonical args missing scopes: %#v", fake.calls[0].args)
}
if _, ok := fake.calls[0].args["scope"]; ok {
t.Fatalf("canonical args should not use legacy scope: %#v", fake.calls[0].args)
}
}
func TestChmod_emptyCanonicalResultReturnsError(t *testing.T) {
fake := &fallbackToolCaller{}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("agentCode", "qoderwork")
_ = cmd.Flags().Set("grant-type", "permanent")
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if err == nil {
t.Fatal("chmod RunE error = nil, want empty PAT authorization result")
}
if !strings.Contains(err.Error(), "empty PAT authorization result") {
t.Fatalf("chmod RunE error = %q, want empty PAT authorization result", err.Error())
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
}
func TestCallPATToolWithLegacyFallback_toolNotFoundRetriesLegacyAlias(t *testing.T) {
fake := &fallbackErrorToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"aitable.record:read"},
"grantType": "permanent",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"aitable.record:read"},
"grantType": "permanent",
}
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err != nil {
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
}
if isEmptyToolResult(result) {
t.Fatalf("fallback result is empty: %#v", result)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
}
if _, ok := fake.calls[1].args["scope"]; !ok {
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
}
if _, ok := fake.calls[1].args["scopes"]; ok {
t.Fatalf("legacy args should not use canonical scopes: %#v", fake.calls[1].args)
}
}
func TestCallPATToolWithLegacyFallback_schemaMismatchRetriesLegacyAlias(t *testing.T) {
fake := &fallbackSchemaMismatchToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"aitable.record:read"},
"grantType": "permanent",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"aitable.record:read"},
"grantType": "permanent",
}
result, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err != nil {
t.Fatalf("callPATToolWithLegacyFallback error = %v", err)
}
if isEmptyToolResult(result) {
t.Fatalf("fallback result is empty: %#v", result)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patGrantToolName)
}
if fake.calls[1].tool != patGrantToolNameLegacyAlias {
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolNameLegacyAlias)
}
if _, ok := fake.calls[1].args["scope"]; !ok {
t.Fatalf("legacy args missing scope: %#v", fake.calls[1].args)
}
}
func TestCallPATToolWithLegacyFallback_permissionDeniedDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackPermissionDeniedToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"chat.message:send"},
"grantType": "once",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"chat.message:send"},
"grantType": "once",
}
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err == nil {
t.Fatal("callPATToolWithLegacyFallback error = nil, want original permission denial")
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.ServerDiag.ServerErrorCode != "PAT_MEDIUM_RISK_NO_PERMISSION" {
t.Fatalf("ServerErrorCode = %q, want PAT_MEDIUM_RISK_NO_PERMISSION", typed.ServerDiag.ServerErrorCode)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
}
func TestCallPATToolWithLegacyFallback_patErrorDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackPATErrorToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"mail:send"},
"grantType": "once",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"mail:send"},
"grantType": "once",
}
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err == nil {
t.Fatal("callPATToolWithLegacyFallback error = nil, want PATError")
}
if !apperrors.IsPATError(err) {
t.Fatalf("expected PATError, got %T", err)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
}
func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t *testing.T) {
fake := &fallbackPATContractErrorToolCaller{}
canonicalArgs := map[string]any{
"agentCode": "qoderwork",
"scopes": []string{"mail:send"},
"grantType": "once",
}
legacyArgs := map[string]any{
"agentCode": "qoderwork",
"scope": []string{"mail:send"},
"grantType": "once",
}
_, err := callPATToolWithLegacyFallback(context.Background(), fake, "pat", patGrantToolName, patGrantToolNameLegacyAlias, canonicalArgs, legacyArgs)
if err == nil {
t.Fatal("callPATToolWithLegacyFallback error = nil, want original PAT contract error")
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.ServerDiag.ServerErrorCode != "PAT_SCOPE_AUTH_REQUIRED" {
t.Fatalf("ServerErrorCode = %q, want PAT_SCOPE_AUTH_REQUIRED", typed.ServerDiag.ServerErrorCode)
}
if len(fake.calls) != 1 {
t.Fatalf("CallTool call count = %d, want 1", len(fake.calls))
}
}
func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) {
fake := &sequenceToolCaller{
responses: []string{
`{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`,
`{"success":true,"data":{"authRequestId":"req-ok"}}`,
},
}
cmd := chmodWithYes(t, newChmodCommand(fake))
_ = cmd.Flags().Set("agentCode", "qoderwork")
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
}
if fake.calls[0].tool != patBatchGrantToolName {
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
}
if fake.calls[1].tool != patGrantToolName {
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("fallback agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) {
t.Fatalf("fallback scopes = %#v, want mail:send", got)
}
}
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
if !isToolNotRegisteredError(err) {
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
}
}
func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PARAM_ERROR",
TechnicalDetail: "Tool metadata API error: PARAM_ERROR - 未找到指定工具",
}),
)
if !isToolNotRegisteredError(err) {
t.Fatalf("isToolNotRegisteredError(%q) = false, want true", err.Error())
}
}
func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"pat_batch_auth_unsupported"}`}}}
if !isPATBatchUnsupportedResult(result) {
t.Fatal("isPATBatchUnsupportedResult() = false, want true")
}
}
func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) {
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}}
if !isPATBatchFallbackResult(result) {
t.Fatal("isPATBatchFallbackResult() = false, want true")
}
}
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_BATCH_AUTH_UNSUPPORTED",
}),
)
if !isPATBatchUnsupportedError(err) {
t.Fatal("isPATBatchUnsupportedError() = false, want true")
}
}
func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED",
}),
)
if !isPATBatchFallbackError(err) {
t.Fatal("isPATBatchFallbackError() = false, want true")
}
}
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
err := handleToolResult(nil, nil, &edition.ToolResult{})
if err == nil {
t.Fatal("handleToolResult error = nil, want empty PAT authorization result error")
}
if !strings.Contains(err.Error(), "empty PAT authorization result") {
t.Fatalf("handleToolResult error = %q, want empty PAT authorization result", err.Error())
}
}
func TestHandleToolResult_defaultSummarizesBatchPlan(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
cmd := &cobra.Command{Use: "chmod"}
root.AddCommand(cmd)
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{
"success": true,
"code": "OK",
"data": {
"agentCode": "ding-agent",
"allGranted": false,
"items": [
{"scope": "calendar.event:list"},
{"scope": "calendar.event:create"}
],
"selectedScopes": ["calendar.event:create"],
"skippedScopes": ["calendar.event:list"],
"pendingScopes": []
}
}`}}}
output, err := captureStdout(t, func() error {
return handleToolResult(cmd, &sequenceToolCaller{dryRun: true}, result)
})
if err != nil {
t.Fatalf("handleToolResult error = %v", err)
}
for _, want := range []string{
"PAT authorization",
"status: OK",
"agentCode: ding-agent",
"allGranted: false",
"items: 2",
"selected: 1",
"skipped: 1",
"pending: 0",
"suggestion: rerun this command without --dry-run to grant selected scopes",
} {
if !strings.Contains(output, want) {
t.Fatalf("summary output missing %q: %s", want, output)
}
}
if strings.Contains(output, "calendar.event:create") || strings.Contains(output, `"items"`) {
t.Fatalf("summary output leaked raw item details: %s", output)
}
}
func TestHandleToolResult_explicitJSONKeepsRawPayload(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
cmd := &cobra.Command{Use: "chmod"}
root.AddCommand(cmd)
if err := root.PersistentFlags().Set("format", "json"); err != nil {
t.Fatalf("set format error = %v", err)
}
text := `{"success":true,"code":"OK","data":{"items":[{"scope":"calendar.event:create"}],"selectedScopes":["calendar.event:create"]}}`
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
output, err := captureStdout(t, func() error {
return handleToolResult(cmd, &sequenceToolCaller{dryRun: true}, result)
})
if err != nil {
t.Fatalf("handleToolResult error = %v", err)
}
if !strings.Contains(output, `"items"`) || !strings.Contains(output, "calendar.event:create") {
t.Fatalf("explicit json output did not preserve raw payload: %s", output)
}
if strings.Contains(output, "PAT authorization") {
t.Fatalf("explicit json output must not be summarized: %s", output)
}
}
func TestHandleToolResult_verboseKeepsRawPayload(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("verbose", false, "")
cmd := &cobra.Command{Use: "chmod"}
root.AddCommand(cmd)
if err := root.PersistentFlags().Set("verbose", "true"); err != nil {
t.Fatalf("set verbose error = %v", err)
}
text := `{"success":true,"code":"OK","data":{"items":[{"scope":"calendar.event:create"}]}}`
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
output, err := captureStdout(t, func() error {
return handleToolResult(cmd, &sequenceToolCaller{}, result)
})
if err != nil {
t.Fatalf("handleToolResult error = %v", err)
}
if !strings.Contains(output, `"items"`) || !strings.Contains(output, "calendar.event:create") {
t.Fatalf("verbose output did not preserve raw payload: %s", output)
}
}
// TestChmod_agentCode_env_invalid verifies that a malformed
// DINGTALK_DWS_AGENTCODE value (whitespace, shell metacharacters) is
// rejected by the regex gate in validateAgentCode before any MCP call
// is attempted.
func TestChmod_agentCode_env_invalid(t *testing.T) {
t.Setenv(agentCodeEnv, "bad value with space!")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
err := cmd.RunE(cmd, []string{"aitable.record:read"})
if err == nil {
t.Fatalf("expected validateAgentCode error, got nil")
}
if !strings.Contains(err.Error(), "invalid agentCode") {
t.Fatalf("error = %q, want to mention 'invalid agentCode'", err.Error())
}
if !strings.Contains(err.Error(), agentCodeEnv) {
t.Fatalf("error = %q, want to attribute to %s env", err.Error(), agentCodeEnv)
}
if fake.callN != 0 {
t.Fatalf("CallTool was invoked %d times; validator must short-circuit before MCP", fake.callN)
}
}
// TestChmod_agentCode_flag_wins_over_env verifies the Priority-1 contract
// of resolveAgentCode: when both the flag and the env are set, the flag
// wins and env is silently ignored (no warning needed because the flag is
// the explicit, scripted intent).
func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Setenv(agentCodeEnv, "qoder")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("agentCode", "QoderWork")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if got := fake.gotAgentEnv; got != "QoderWork" {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "QoderWork")
}
if got := fake.gotArgs["agentCode"]; got != "QoderWork" {
t.Fatalf("batch agentCode = %#v, want QoderWork", got)
}
}
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only
// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names
// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can
// apply its open-source default.
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv(agentCodeEnvCompat, "")
t.Setenv("DWS_AGENTCODE", "legacyval")
t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
}
if fake.callN != 1 {
t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got)
}
}
// ---------------------------------------------------------------------------
// validateAgentCode / resolveAgentCodeFromEnv unit tests
// ---------------------------------------------------------------------------
func TestValidateAgentCode(t *testing.T) {
t.Parallel()
cases := []struct {
in string
wantErr bool
}{
{"qoderwork", false},
{"agt-abc123", false},
{"Agt_Xyz-09", false},
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", false}, // 64 chars
{"", true},
{"bad value", true},
{"bad!chars", true},
{"中文不行", true},
{"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789X", true}, // 65
}
for _, tc := range cases {
err := validateAgentCode(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("validateAgentCode(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr)
}
}
}
func TestResolveAgentCodeFromEnv(t *testing.T) {
// Not parallel: mutates process env.
// DINGTALK_DWS_AGENTCODE is honoured and trimmed.
t.Setenv(agentCodeEnv, " qoderwork ")
if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want (%q, %q)",
code, src, "qoderwork", agentCodeEnv)
}
// Reverse-guard: the draft reversed spelling is intentionally ignored.
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored",
code, src)
}
// Empty primary → ("", "").
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", "")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
}
// Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the
// canonical env is unset — it was hard-removed as a legacy alias.
t.Setenv(agentCodeEnv, "")
t.Setenv(agentCodeEnvCompat, "")
t.Setenv("DWS_AGENTCODE", "legacy")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored",
code, src)
}
}
func (f *fallbackErrorToolCaller) Fields() string { return "" }
func (f *fallbackErrorToolCaller) JQ() string { return "" }
func (f *fallbackSchemaMismatchToolCaller) Fields() string { return "" }
func (f *fallbackSchemaMismatchToolCaller) JQ() string { return "" }
func (f *fallbackPermissionDeniedToolCaller) Fields() string { return "" }
func (f *fallbackPermissionDeniedToolCaller) JQ() string { return "" }
func (f *fallbackPATErrorToolCaller) Fields() string { return "" }
func (f *fallbackPATErrorToolCaller) JQ() string { return "" }