272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
package transport
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestWithAuth_ClonesCorrectly(t *testing.T) {
|
|
t.Parallel()
|
|
original := NewClient(nil)
|
|
original.AuthToken = "original-token"
|
|
original.ExtraHeaders = map[string]string{"X-Old": "val"}
|
|
original.TrustedDomains = []string{"*.example.com"}
|
|
|
|
clone := original.WithAuth("new-token", map[string]string{"X-New": "val2"})
|
|
|
|
if clone.AuthToken != "new-token" {
|
|
t.Fatalf("clone token = %s, want new-token", clone.AuthToken)
|
|
}
|
|
if clone.ExtraHeaders["X-New"] != "val2" {
|
|
t.Fatal("clone missing new header")
|
|
}
|
|
// Original unchanged
|
|
if original.AuthToken != "original-token" {
|
|
t.Fatal("original token was modified")
|
|
}
|
|
// Shared HTTP client
|
|
if clone.HTTPClient != original.HTTPClient {
|
|
t.Fatal("HTTP client should be shared")
|
|
}
|
|
// Trusted domains propagated
|
|
if len(clone.TrustedDomains) != 1 || clone.TrustedDomains[0] != "*.example.com" {
|
|
t.Fatal("trusted domains not propagated")
|
|
}
|
|
}
|
|
|
|
func TestMatchDomain(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
host, pattern string
|
|
want bool
|
|
}{
|
|
{"api.dingtalk.com", "*.dingtalk.com", true},
|
|
{"dingtalk.com", "*.dingtalk.com", true},
|
|
{"api.dingtalk.com", "api.dingtalk.com", true},
|
|
{"evil.com", "*.dingtalk.com", false},
|
|
{"api.dingtalk.com", "", false},
|
|
{"API.DINGTALK.COM", "*.dingtalk.com", true},
|
|
{"localhost", "localhost", true},
|
|
{"localhost", "*.localhost", true}, // matches host == pattern[2:]
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.host+"_"+tt.pattern, func(t *testing.T) {
|
|
if got := matchDomain(tt.host, tt.pattern); got != tt.want {
|
|
t.Fatalf("matchDomain(%s, %s) = %v, want %v", tt.host, tt.pattern, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactURL(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"https://api.example.com/path", "https://api.example.com/path"},
|
|
{"https://api.example.com/path?key=secret&token=abc", ""},
|
|
{"not a url ://", "not a url ://"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := RedactURL(tt.input)
|
|
if tt.want == "" {
|
|
// Just check that secrets are redacted
|
|
if strings.Contains(got, "secret") || strings.Contains(got, "abc") {
|
|
t.Fatalf("RedactURL(%s) still contains secrets: %s", tt.input, got)
|
|
}
|
|
} else if got != tt.want {
|
|
t.Fatalf("RedactURL(%s) = %s, want %s", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var logBuf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
var requestedURL string
|
|
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
requestedURL = req.URL.String()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{}}`)),
|
|
Request: req,
|
|
}, nil
|
|
})})
|
|
client.FileLogger = logger
|
|
|
|
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`), "initialize")
|
|
if err != nil {
|
|
t.Fatalf("doWithRetry() error = %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if !strings.Contains(requestedURL, "key=secret") {
|
|
t.Fatalf("gateway request URL = %q, want preserved key query", requestedURL)
|
|
}
|
|
out := logBuf.String()
|
|
if strings.Contains(out, "key=secret") || strings.Contains(out, "secret") {
|
|
t.Fatalf("debug log leaked gateway key: %s", out)
|
|
}
|
|
if !strings.Contains(out, "key=REDACTED") {
|
|
t.Fatalf("debug log did not include redacted endpoint, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeBearerToken(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"valid-token-123", "valid-token-123"},
|
|
{" spaced ", "spaced"},
|
|
{"", ""},
|
|
{"has\x00null", ""},
|
|
{"has\nnewline", ""},
|
|
{"has\ttab", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := sanitizeBearerToken(tt.input); got != tt.want {
|
|
t.Fatalf("sanitizeBearerToken(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWarnWildcardDomains_OnlyOnce(t *testing.T) {
|
|
t.Parallel()
|
|
var buf bytes.Buffer
|
|
c := NewClient(nil)
|
|
c.Stderr = &buf
|
|
|
|
c.warnWildcardDomains()
|
|
c.warnWildcardDomains()
|
|
c.warnWildcardDomains()
|
|
|
|
lines := strings.Count(buf.String(), "[WARN]")
|
|
if lines != 1 {
|
|
t.Fatalf("expected 1 warning, got %d: %s", lines, buf.String())
|
|
}
|
|
}
|
|
|
|
func TestTrustedDomainsList_FromField(t *testing.T) {
|
|
t.Parallel()
|
|
c := &Client{TrustedDomains: []string{"a.com", "b.com"}}
|
|
got := c.trustedDomainsList()
|
|
if len(got) != 2 || got[0] != "a.com" {
|
|
t.Fatalf("expected field domains, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestTrustedDomainsList_FromEnv(t *testing.T) {
|
|
t.Setenv("DWS_TRUSTED_DOMAINS", "x.com,y.com")
|
|
c := &Client{}
|
|
got := c.trustedDomainsList()
|
|
if len(got) != 2 || got[0] != "x.com" {
|
|
t.Fatalf("expected env domains, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestTrustedDomainsList_Default(t *testing.T) {
|
|
t.Setenv("DWS_TRUSTED_DOMAINS", "")
|
|
c := &Client{}
|
|
got := c.trustedDomainsList()
|
|
if len(got) == 0 {
|
|
t.Fatal("expected default domains")
|
|
}
|
|
}
|
|
|
|
func TestIsEndpointTrusted_WildcardTriggersWarning(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
c := NewClient(nil)
|
|
c.TrustedDomains = []string{"*"}
|
|
c.Stderr = &buf
|
|
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
|
|
|
result := c.isEndpointTrusted("https://evil.com/api")
|
|
if !result {
|
|
t.Fatal("wildcard should trust all HTTPS endpoints")
|
|
}
|
|
if !strings.Contains(buf.String(), "[WARN]") {
|
|
t.Fatalf("expected warning, got: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestIsEndpointTrusted_HTTPRejected(t *testing.T) {
|
|
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
|
c := NewClient(nil)
|
|
c.TrustedDomains = []string{"*"}
|
|
result := c.isEndpointTrusted("http://evil.com/api")
|
|
if result {
|
|
t.Fatal("plain HTTP to non-loopback should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestIsEndpointTrusted_HTTPLoopbackAllowed(t *testing.T) {
|
|
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
|
c := NewClient(nil)
|
|
c.TrustedDomains = []string{"*"}
|
|
var buf bytes.Buffer
|
|
c.Stderr = &buf
|
|
result := c.isEndpointTrusted("http://127.0.0.1:8080/api")
|
|
if !result {
|
|
t.Fatal("HTTP loopback with env var should be allowed")
|
|
}
|
|
}
|
|
|
|
func TestIsEndpointTrusted_DomainMatch(t *testing.T) {
|
|
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
|
c := NewClient(nil)
|
|
c.TrustedDomains = []string{"*.dingtalk.com"}
|
|
if !c.isEndpointTrusted("https://api.dingtalk.com/v1") {
|
|
t.Fatal("should trust *.dingtalk.com")
|
|
}
|
|
if c.isEndpointTrusted("https://evil.com/v1") {
|
|
t.Fatal("should not trust evil.com")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageIsEndpointTrustedDefaultIncludesDingTalkInternational(t *testing.T) {
|
|
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
|
t.Setenv("DWS_TRUSTED_DOMAINS", "")
|
|
c := NewClient(nil)
|
|
if !c.isEndpointTrusted("https://mcp-gw.dingtalk.com/server/contact") {
|
|
t.Fatal("default trusted domains should trust dingtalk.com")
|
|
}
|
|
if !c.isEndpointTrusted("https://pre-mcp-gw.dingtalk.io/server/contact") {
|
|
t.Fatal("default trusted domains should trust dingtalk.io")
|
|
}
|
|
for _, host := range []string{"mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io"} {
|
|
if !shouldPreserveEndpointQuery(&url.URL{Scheme: "https", Host: host}) {
|
|
t.Fatalf("international gateway %q should preserve endpoint query", host)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHttpStatusError(t *testing.T) {
|
|
t.Parallel()
|
|
codes := []int{
|
|
http.StatusUnauthorized,
|
|
http.StatusForbidden,
|
|
http.StatusNotFound,
|
|
http.StatusTooManyRequests,
|
|
http.StatusBadGateway,
|
|
http.StatusInternalServerError,
|
|
}
|
|
for _, code := range codes {
|
|
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, "", "")
|
|
if err == nil {
|
|
t.Fatalf("expected error for status %d", code)
|
|
}
|
|
}
|
|
}
|