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>
2075 lines
76 KiB
Go
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 "" }
|