Files
dingtalk-workspace-cli/internal/transport/client_test.go
2026-08-13 14:46:34 +08:00

661 lines
19 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 transport
import (
"context"
"encoding/json"
stderrors "errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestInitializeNegotiatesProtocolVersion(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
params := req["params"].(map[string]any)
version := params["protocolVersion"].(string)
if version == "2025-03-26" {
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]any{
"code": -32600,
"message": "unsupported",
},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "demo", "version": "1.0.0"},
},
})
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.Initialize(context.Background(), server.URL)
if err != nil {
t.Fatalf("Initialize() error = %v", err)
}
if result.ProtocolVersion != "2024-11-05" {
t.Fatalf("Initialize() selected %q, want 2024-11-05", result.ProtocolVersion)
}
}
func TestInitializeShortCircuitsOnHTTPError(t *testing.T) {
t.Parallel()
// Track which protocol versions actually get sent. With the short-circuit
// in place, a transport-layer (HTTP) failure should fail Initialize on the
// FIRST version without iterating through every supported version. Without
// the short-circuit, three round-trips would happen — needlessly tripling
// every CLI startup when a plugin endpoint is broken (issue #119).
var seenVersions []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
params := req["params"].(map[string]any)
seenVersions = append(seenVersions, params["protocolVersion"].(string))
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient(server.Client())
client.MaxRetries = 0 // skip the HTTP retry loop — we only care about version iteration
if _, err := client.Initialize(context.Background(), server.URL); err == nil {
t.Fatal("Initialize() error = nil, want HTTP error")
}
if len(seenVersions) != 1 {
t.Fatalf("Initialize() attempted %d protocol versions (%v), want 1 — HTTP failures must short-circuit",
len(seenVersions), seenVersions)
}
}
func TestInitializeShortCircuitsOnDialFailure(t *testing.T) {
t.Parallel()
// Bind to an ephemeral port, then close the listener. Subsequent connects
// to that address fail with "connection refused" almost instantly. With
// the short-circuit, three protocol versions would otherwise stack three
// dial-error returns; we only want one — and we want Initialize to return
// well under the per-dial budget.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
endpoint := "http://" + ln.Addr().String()
_ = ln.Close()
client := NewClient(nil)
client.MaxRetries = 0
start := time.Now()
if _, err := client.Initialize(context.Background(), endpoint); err == nil {
t.Fatal("Initialize() error = nil, want dial failure")
}
elapsed := time.Since(start)
// Three dial attempts (one per supported version) on a refused-connection
// path is still fast on loopback, so this assertion is a sanity bound, not
// the primary signal — but if the short-circuit regresses, on a real
// unreachable address this jumps from one dial timeout to three.
if elapsed > 2*time.Second {
t.Fatalf("Initialize() took %v, want <2s — dial failure should short-circuit", elapsed)
}
}
func TestListToolsRetriesOnServerError(t *testing.T) {
t.Parallel()
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 2,
"result": map[string]any{
"tools": []map[string]any{
{
"name": "create_document",
"title": "创建文档",
"description": "创建文档",
"inputSchema": map[string]any{"type": "object"},
},
},
},
})
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.ListTools(context.Background(), server.URL)
if err != nil {
t.Fatalf("ListTools() error = %v", err)
}
if attempts != 2 {
t.Fatalf("ListTools() attempts = %d, want 2", attempts)
}
if len(result.Tools) != 1 {
t.Fatalf("ListTools() len = %d, want 1", len(result.Tools))
}
}
func TestListToolsUsesExponentialBackoffWithCap(t *testing.T) {
t.Parallel()
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts <= 3 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 2,
"result": map[string]any{"tools": []map[string]any{}},
})
}))
defer server.Close()
var delays []time.Duration
client := NewClient(server.Client())
client.MaxRetries = 3
client.RetryDelay = 10 * time.Millisecond
client.RetryMaxDelay = 25 * time.Millisecond
client.sleep = func(_ context.Context, delay time.Duration) error {
delays = append(delays, delay)
return nil
}
if _, err := client.ListTools(context.Background(), server.URL); err != nil {
t.Fatalf("ListTools() error = %v", err)
}
want := []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 25 * time.Millisecond}
if !reflect.DeepEqual(delays, want) {
t.Fatalf("retry delays = %#v, want %#v", delays, want)
}
}
func TestListToolsHonorsRetryAfterHeader(t *testing.T) {
t.Parallel()
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", "3")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 2,
"result": map[string]any{"tools": []map[string]any{}},
})
}))
defer server.Close()
var delays []time.Duration
client := NewClient(server.Client())
client.MaxRetries = 1
client.RetryDelay = 10 * time.Millisecond
client.RetryMaxDelay = 5 * time.Second
client.sleep = func(_ context.Context, delay time.Duration) error {
delays = append(delays, delay)
return nil
}
if _, err := client.ListTools(context.Background(), server.URL); err != nil {
t.Fatalf("ListTools() error = %v", err)
}
want := []time.Duration{3 * time.Second}
if !reflect.DeepEqual(delays, want) {
t.Fatalf("retry delays = %#v, want %#v", delays, want)
}
}
func TestCallToolUsesJSONRPCMethod(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req["method"] != "tools/call" {
t.Fatalf("method = %#v, want tools/call", req["method"])
}
params := req["params"].(map[string]any)
if params["name"] != "create_document" {
t.Fatalf("tool name = %#v, want create_document", params["name"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 3,
"result": map[string]any{
"content": map[string]any{
"documentId": "doc-123",
},
},
})
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if result.Content["documentId"] != "doc-123" {
t.Fatalf("CallTool() content = %#v", result.Content)
}
}
func TestCrossPlatformCoverageCallToolDevAppEventSubscribeRetriesAreBounded(t *testing.T) {
attempts := 0
httpClient := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempts++
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
}),
}
client := NewClient(httpClient)
client.sleep = func(context.Context, time.Duration) error { return nil }
_, err := client.CallTool(
context.Background(),
"https://mcp.example.test/dws",
"subscribe_dev_app_events",
map[string]any{
"unifiedAppId": "u-1",
"eventCodes": []string{"event-a", "event-b"},
},
)
if err == nil {
t.Fatal("CallTool() succeeded after repeated HTTP 503 responses")
}
wantAttempts := client.MaxRetries + 1
if attempts != wantAttempts {
t.Fatalf("HTTP attempts = %d, want configured bound %d", attempts, wantAttempts)
}
if attempts > 2 {
t.Fatalf("dev app event subscribe HTTP attempts = %d, want at most 2 by default", attempts)
}
}
func TestCallToolInjectsAuthHeaders(t *testing.T) {
t.Parallel()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Fatalf("Authorization header = %q, want Bearer test-token", got)
}
if got := r.Header.Get("x-user-access-token"); got != "test-token" {
t.Fatalf("x-user-access-token header = %q, want test-token", got)
}
if got := r.Header.Get("Accept"); got != "application/json" {
t.Fatalf("Accept header = %q, want application/json", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 3,
"result": map[string]any{
"content": map[string]any{
"ok": true,
},
},
})
}))
defer server.Close()
client := NewClient(server.Client())
client.AuthToken = "test-token"
client.TrustedDomains = []string{"127.0.0.1"}
result, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if result.Content["ok"] != true {
t.Fatalf("CallTool() content = %#v, want ok=true", result.Content)
}
}
func TestCallToolAcceptsStructuredContentResults(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 3,
"result": map[string]any{
"content": []map[string]any{
{
"type": "text",
"text": `{"ignored":true}`,
},
},
"structuredContent": map[string]any{
"success": true,
"result": map[string]any{
"documentId": "doc-structured",
},
},
"isError": false,
},
})
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if result.Content["success"] != true {
t.Fatalf("success = %#v, want true", result.Content["success"])
}
payload, ok := result.Content["result"].(map[string]any)
if !ok {
t.Fatalf("result.Content[result] = %#v, want object", result.Content["result"])
}
if payload["documentId"] != "doc-structured" {
t.Fatalf("documentId = %#v, want doc-structured", payload["documentId"])
}
}
func TestCallToolClassifiesUnauthorizedHTTPAsAuthError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
root := t.TempDir()
client := NewClient(server.Client())
client.MaxRetries = 0
client.SnapshotRecorder = testSnapshotRecorder{root: root}
_, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err == nil {
t.Fatal("CallTool() error = nil, want auth error")
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
}
if typed.Category != apperrors.CategoryAuth {
t.Fatalf("category = %q, want auth", typed.Category)
}
if typed.Reason != "http_401" {
t.Fatalf("reason = %q, want http_401", typed.Reason)
}
if typed.Snapshot == "" {
t.Fatal("snapshot path should not be empty")
}
}
func TestCallToolClassifiesForbiddenHTTPAsAuthError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer server.Close()
client := NewClient(server.Client())
client.MaxRetries = 0
_, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err == nil {
t.Fatal("CallTool() error = nil, want auth error")
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
}
if typed.Category != apperrors.CategoryAuth {
t.Fatalf("category = %q, want auth", typed.Category)
}
if typed.Reason != "http_403" {
t.Fatalf("reason = %q, want http_403", typed.Reason)
}
}
func TestCallToolClassifiesJSONRPCEnvelopeErrorsAsAPIErrors(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 3,
"error": map[string]any{
"code": -32603,
"message": "upstream timeout",
},
})
}))
defer server.Close()
root := t.TempDir()
client := NewClient(server.Client())
client.SnapshotRecorder = testSnapshotRecorder{root: root}
_, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err == nil {
t.Fatal("CallTool() error = nil, want api error")
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
}
if typed.Category != apperrors.CategoryAPI {
t.Fatalf("category = %q, want api", typed.Category)
}
if typed.Reason != "tools_call_jsonrpc_internal_error" {
t.Fatalf("reason = %q, want tools_call_jsonrpc_internal_error", typed.Reason)
}
if typed.Snapshot == "" {
t.Fatal("snapshot path should not be empty")
}
}
func TestCallToolClassifiesJSONRPCInvalidParamsAsValidationError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 3,
"error": map[string]any{
"code": -32602,
"message": "invalid arguments",
},
})
}))
defer server.Close()
client := NewClient(server.Client())
_, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err == nil {
t.Fatal("CallTool() error = nil, want validation error")
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
}
if typed.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", typed.Category)
}
if typed.Reason != "tools_call_jsonrpc_invalid_params" {
t.Fatalf("reason = %q, want tools_call_jsonrpc_invalid_params", typed.Reason)
}
}
func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
endpoint string
want string
}{
{
name: "prepub gateway",
endpoint: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret",
},
{
name: "prod gateway",
endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
},
{
name: "prepub international gateway",
endpoint: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret#frag",
want: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret",
},
{
name: "prod international gateway",
endpoint: "https://mcp-gw.dingtalk.io/server/demo?key=secret#frag",
want: "https://mcp-gw.dingtalk.io/server/demo?key=secret",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := sanitizeJSONRPCEndpoint(tt.endpoint); got != tt.want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeJSONRPCEndpointStripsQueryForOtherHosts(t *testing.T) {
t.Parallel()
got := sanitizeJSONRPCEndpoint("https://example.com/server/demo?admin=true#frag")
want := "https://example.com/server/demo"
if got != want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
}
}
func TestSanitizeJSONRPCEndpointStripsQueryForHTTPGateway(t *testing.T) {
t.Parallel()
got := sanitizeJSONRPCEndpoint("http://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag")
want := "http://pre-mcp-gw.dingtalk.com/server/demo"
if got != want {
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
}
}
type testSnapshotRecorder struct {
root string
}
func (r testSnapshotRecorder) RecordJSONRPC(method, endpoint string, requestBody, responseBody []byte) string {
path := filepath.Join(r.root, "snapshot.json")
_ = os.WriteFile(path, responseBody, 0o644)
return path
}
func TestCallToolPreservesJSONRPCErrorData(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": 3,
"error": map[string]any{
"code": -32602,
"message": "invalid arguments",
"data": map[string]any{
"field": "base_id",
"error": "required",
},
},
})
}))
defer server.Close()
client := NewClient(server.Client())
_, err := client.CallTool(context.Background(), server.URL, "create_document", map[string]any{"title": "Quarterly Report"})
if err == nil {
t.Fatal("CallTool() error = nil, want validation error")
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
}
if typed.RPCCode != -32602 {
t.Fatalf("RPCCode = %d, want -32602", typed.RPCCode)
}
if len(typed.RPCData) == 0 {
t.Fatal("RPCData should not be empty")
}
var data map[string]any
if jsonErr := json.Unmarshal(typed.RPCData, &data); jsonErr != nil {
t.Fatalf("json.Unmarshal(RPCData) error = %v", jsonErr)
}
if data["field"] != "base_id" {
t.Fatalf("RPCData.field = %#v, want base_id", data["field"])
}
}