mirror of
https://github.com/alibaba/higress.git
synced 2026-06-26 02:35:02 +08:00
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
440 lines
15 KiB
Go
440 lines
15 KiB
Go
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
|
//
|
|
// 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 server
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestMcpProxyServerBasicInterface tests that McpProxyServer implements the Server interface
|
|
func TestMcpProxyServerBasicInterface(t *testing.T) {
|
|
// This test will fail until we implement McpProxyServer
|
|
server := NewMcpProxyServer("test-proxy")
|
|
|
|
// Test Server interface implementation
|
|
assert.NotNil(t, server)
|
|
assert.Equal(t, "test-proxy", server.Name)
|
|
|
|
// Test that it implements all required methods
|
|
tools := server.GetMCPTools()
|
|
assert.NotNil(t, tools)
|
|
assert.Equal(t, 0, len(tools))
|
|
|
|
// Test Clone method
|
|
cloned := server.Clone()
|
|
assert.NotNil(t, cloned)
|
|
}
|
|
|
|
// TestMcpProxyServerConfiguration tests configuration setting and getting
|
|
func TestMcpProxyServerConfiguration(t *testing.T) {
|
|
server := NewMcpProxyServer("test-proxy")
|
|
|
|
// Set server fields directly
|
|
server.SetMcpServerURL("http://backend.example.com/mcp")
|
|
server.SetTimeout(5000)
|
|
|
|
// Add security scheme
|
|
scheme := SecurityScheme{
|
|
ID: "test-auth",
|
|
Type: "apiKey",
|
|
In: "header",
|
|
Name: "X-API-Key",
|
|
}
|
|
server.AddSecurityScheme(scheme)
|
|
|
|
// Verify server fields
|
|
assert.Equal(t, "http://backend.example.com/mcp", server.GetMcpServerURL())
|
|
assert.Equal(t, 5000, server.GetTimeout())
|
|
|
|
// Verify security scheme
|
|
retrievedScheme, exists := server.GetSecurityScheme("test-auth")
|
|
assert.True(t, exists)
|
|
assert.Equal(t, "test-auth", retrievedScheme.ID)
|
|
assert.Equal(t, "apiKey", retrievedScheme.Type)
|
|
}
|
|
|
|
// TestMcpProxyServerAddTool tests adding proxy tools
|
|
func TestMcpProxyServerAddTool(t *testing.T) {
|
|
server := NewMcpProxyServer("test-proxy")
|
|
|
|
toolConfig := McpProxyToolConfig{
|
|
Name: "test-tool",
|
|
Description: "Test tool for proxy",
|
|
Args: []ToolArg{
|
|
{
|
|
Name: "input",
|
|
Description: "Test input",
|
|
Type: "string",
|
|
Required: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := server.AddProxyTool(toolConfig)
|
|
assert.NoError(t, err)
|
|
|
|
tools := server.GetMCPTools()
|
|
assert.Len(t, tools, 1)
|
|
assert.Contains(t, tools, "test-tool")
|
|
}
|
|
|
|
// TestMcpProxyServerSecuritySchemes tests security scheme management
|
|
func TestMcpProxyServerSecuritySchemes(t *testing.T) {
|
|
server := NewMcpProxyServer("test-proxy")
|
|
|
|
scheme := SecurityScheme{
|
|
ID: "test-auth",
|
|
Type: "apiKey",
|
|
In: "header",
|
|
Name: "X-API-Key",
|
|
}
|
|
|
|
server.AddSecurityScheme(scheme)
|
|
|
|
retrievedScheme, exists := server.GetSecurityScheme("test-auth")
|
|
assert.True(t, exists)
|
|
assert.Equal(t, scheme.ID, retrievedScheme.ID)
|
|
assert.Equal(t, scheme.Type, retrievedScheme.Type)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// SetDefaultDownstreamSecurity / SetDefaultUpstreamSecurity / PassthroughAuth
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyServer_SetGetDefaultDownstreamSecurity(t *testing.T) {
|
|
s := NewMcpProxyServer("p")
|
|
assert.Equal(t, "", s.GetDefaultDownstreamSecurity().ID, "fresh server has empty default")
|
|
s.SetDefaultDownstreamSecurity(SecurityRequirement{ID: "K", Passthrough: true})
|
|
got := s.GetDefaultDownstreamSecurity()
|
|
assert.Equal(t, "K", got.ID)
|
|
assert.True(t, got.Passthrough)
|
|
}
|
|
|
|
func TestMcpProxyServer_SetGetDefaultUpstreamSecurity(t *testing.T) {
|
|
s := NewMcpProxyServer("p")
|
|
s.SetDefaultUpstreamSecurity(SecurityRequirement{ID: "U", Credential: "c"})
|
|
got := s.GetDefaultUpstreamSecurity()
|
|
assert.Equal(t, "U", got.ID)
|
|
assert.Equal(t, "c", got.Credential)
|
|
}
|
|
|
|
func TestMcpProxyServer_PassthroughAuthHeaderGetterAndSetter(t *testing.T) {
|
|
s := NewMcpProxyServer("p")
|
|
assert.False(t, s.GetPassthroughAuthHeader(), "default is false")
|
|
s.SetPassthroughAuthHeader(true)
|
|
assert.True(t, s.GetPassthroughAuthHeader())
|
|
s.SetPassthroughAuthHeader(false)
|
|
assert.False(t, s.GetPassthroughAuthHeader())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// AddSecurityScheme — nil-map branch
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyServer_AddSecurityScheme_InitializesNilMap(t *testing.T) {
|
|
// Skip the constructor so we can hit the `securitySchemes == nil` branch.
|
|
s := &McpProxyServer{Name: "p"}
|
|
s.AddSecurityScheme(SecurityScheme{ID: "K", Type: "apiKey", In: "header", Name: "X"})
|
|
got, ok := s.GetSecurityScheme("K")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "K", got.ID)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// AddMCPTool — delegates to BaseMCPServer
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyServer_AddMCPTool_StoresInBaseAndReturnsSelf(t *testing.T) {
|
|
s := NewMcpProxyServer("p")
|
|
stub := &stubTool{desc: "d"}
|
|
ret := s.AddMCPTool("custom", stub)
|
|
assert.Same(t, s, ret, "AddMCPTool returns receiver for chaining")
|
|
tools := s.GetMCPTools()
|
|
got, ok := tools["custom"]
|
|
require.True(t, ok)
|
|
assert.Same(t, stub, got)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// AddProxyTool — overrides on duplicate name
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyServer_AddProxyTool_DuplicateNameOverwrites(t *testing.T) {
|
|
s := NewMcpProxyServer("p")
|
|
require.NoError(t, s.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "first"}))
|
|
require.NoError(t, s.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "second"}))
|
|
|
|
tools := s.GetMCPTools()
|
|
assert.Len(t, tools, 1, "duplicate AddProxyTool should overwrite, not duplicate")
|
|
cfg, ok := s.GetToolConfig("t")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "second", cfg.Description, "later AddProxyTool wins")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// GetToolConfig — hit and miss
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyServer_GetToolConfig_HitAndMiss(t *testing.T) {
|
|
s := NewMcpProxyServer("p")
|
|
require.NoError(t, s.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "d"}))
|
|
|
|
cfg, ok := s.GetToolConfig("t")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "d", cfg.Description)
|
|
|
|
_, missOK := s.GetToolConfig("missing")
|
|
assert.False(t, missOK)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Clone — deep copy of toolsConfig and securitySchemes
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyServer_Clone_DeepCopiesToolsConfigAndSchemes(t *testing.T) {
|
|
orig := NewMcpProxyServer("orig")
|
|
orig.SetMcpServerURL("http://b")
|
|
orig.SetTimeout(1234)
|
|
orig.SetTransport(TransportSSE)
|
|
orig.SetPassthroughAuthHeader(true)
|
|
orig.SetDefaultDownstreamSecurity(SecurityRequirement{ID: "K"})
|
|
orig.AddSecurityScheme(SecurityScheme{ID: "K", Type: "apiKey", In: "header", Name: "X"})
|
|
require.NoError(t, orig.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "d"}))
|
|
|
|
clonedI := orig.Clone()
|
|
cloned, ok := clonedI.(*McpProxyServer)
|
|
require.True(t, ok)
|
|
require.NotSame(t, orig, cloned, "Clone must return a fresh struct")
|
|
|
|
// Surface fields are copied.
|
|
assert.Equal(t, orig.Name, cloned.Name)
|
|
// NOTE: Clone does not propagate mcpServerURL/timeout/transport/passthrough
|
|
// nor defaultDownstream/upstreamSecurity. That is intentional today (see
|
|
// proxy_server.go:188): cloning is used for per-request isolation of
|
|
// tool/security registries only. This test pins that contract — if Clone
|
|
// starts copying those fields, update here and document the change.
|
|
assert.Equal(t, "", cloned.GetMcpServerURL())
|
|
|
|
// toolsConfig: deep copy — adding to clone doesn't bleed back to orig.
|
|
require.NoError(t, cloned.AddProxyTool(McpProxyToolConfig{Name: "extra", Description: "x"}))
|
|
_, origHasExtra := orig.GetToolConfig("extra")
|
|
assert.False(t, origHasExtra, "tool added to clone must not appear in original")
|
|
|
|
// securitySchemes: deep copy — replacing scheme on clone doesn't touch orig.
|
|
cloned.AddSecurityScheme(SecurityScheme{ID: "K", Type: "http", Scheme: "bearer"})
|
|
origScheme, _ := orig.GetSecurityScheme("K")
|
|
clonedScheme, _ := cloned.GetSecurityScheme("K")
|
|
assert.Equal(t, "apiKey", origScheme.Type, "original scheme must remain apiKey")
|
|
assert.Equal(t, "http", clonedScheme.Type, "clone reflects the override")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// McpProxyTool — Description / InputSchema / OutputSchema / Create
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestMcpProxyTool_DescriptionAndOutputSchema(t *testing.T) {
|
|
tool := &McpProxyTool{
|
|
toolConfig: McpProxyToolConfig{
|
|
Description: "describe me",
|
|
OutputSchema: map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
assert.Equal(t, "describe me", tool.Description())
|
|
assert.Equal(t, map[string]any{"type": "string"}, tool.OutputSchema())
|
|
}
|
|
|
|
func TestMcpProxyTool_InputSchema_RequiredAndOptionalAndEnumAndDefault(t *testing.T) {
|
|
tool := &McpProxyTool{
|
|
toolConfig: McpProxyToolConfig{
|
|
Args: []ToolArg{
|
|
{Name: "must", Type: "string", Description: "required", Required: true},
|
|
{Name: "opt", Type: "integer", Description: "optional", Default: 7},
|
|
{Name: "pick", Type: "string", Description: "enum", Enum: []interface{}{"a", "b"}},
|
|
},
|
|
},
|
|
}
|
|
schema := tool.InputSchema()
|
|
assert.Equal(t, "object", schema["type"])
|
|
required, ok := schema["required"].([]string)
|
|
require.True(t, ok)
|
|
assert.Equal(t, []string{"must"}, required, "only Required:true args land in required[]")
|
|
|
|
props := schema["properties"].(map[string]any)
|
|
mustProp := props["must"].(map[string]any)
|
|
assert.Equal(t, "string", mustProp["type"])
|
|
|
|
optProp := props["opt"].(map[string]any)
|
|
assert.Equal(t, 7, optProp["default"])
|
|
|
|
pickProp := props["pick"].(map[string]any)
|
|
assert.Equal(t, []interface{}{"a", "b"}, pickProp["enum"])
|
|
}
|
|
|
|
func TestMcpProxyTool_InputSchema_NoArgs(t *testing.T) {
|
|
tool := &McpProxyTool{toolConfig: McpProxyToolConfig{}}
|
|
schema := tool.InputSchema()
|
|
props, ok := schema["properties"].(map[string]any)
|
|
require.True(t, ok)
|
|
assert.Empty(t, props)
|
|
required, ok := schema["required"].([]string)
|
|
require.True(t, ok)
|
|
assert.Empty(t, required)
|
|
}
|
|
|
|
func TestMcpProxyTool_Create_NewInstanceWithBoundArgs(t *testing.T) {
|
|
orig := &McpProxyTool{
|
|
serverName: "srv",
|
|
name: "t",
|
|
toolConfig: McpProxyToolConfig{Name: "t"},
|
|
}
|
|
created := orig.Create([]byte(`{"q":"hello","n":7}`))
|
|
require.NotSame(t, orig, created, "Create returns a fresh instance")
|
|
cloned := created.(*McpProxyTool)
|
|
assert.Equal(t, "srv", cloned.serverName)
|
|
assert.Equal(t, "t", cloned.name)
|
|
assert.Equal(t, "hello", cloned.arguments["q"])
|
|
// JSON unmarshals numbers as float64.
|
|
assert.Equal(t, float64(7), cloned.arguments["n"])
|
|
}
|
|
|
|
func TestMcpProxyTool_Create_EmptyParamsStillReturnsInstance(t *testing.T) {
|
|
orig := &McpProxyTool{serverName: "s", name: "t"}
|
|
created := orig.Create(nil)
|
|
cloned := created.(*McpProxyTool)
|
|
assert.Equal(t, "s", cloned.serverName)
|
|
assert.Equal(t, "t", cloned.name)
|
|
require.NotNil(t, cloned.arguments)
|
|
assert.Empty(t, cloned.arguments, "no params → empty arguments map, not nil")
|
|
}
|
|
|
|
func TestMcpProxyTool_Create_MalformedJSON_PreservesEmptyArgs(t *testing.T) {
|
|
orig := &McpProxyTool{serverName: "s", name: "t"}
|
|
created := orig.Create([]byte(`{not json`))
|
|
cloned := created.(*McpProxyTool)
|
|
// json.Unmarshal silently fails; arguments stays empty.
|
|
assert.Empty(t, cloned.arguments)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// ValidateSecurityScheme — full matrix
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestValidateSecurityScheme(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
scheme SecurityScheme
|
|
wantErr string
|
|
}{
|
|
{"missing ID", SecurityScheme{Type: "apiKey", In: "header", Name: "X"}, "ID is required"},
|
|
{"invalid type", SecurityScheme{ID: "k", Type: "oauth2"}, "invalid security scheme type"},
|
|
{"apiKey missing name", SecurityScheme{ID: "k", Type: "apiKey", In: "header"}, "name is required"},
|
|
{"apiKey invalid in", SecurityScheme{ID: "k", Type: "apiKey", In: "body", Name: "X"}, "invalid security scheme location"},
|
|
{"apiKey ok header", SecurityScheme{ID: "k", Type: "apiKey", In: "header", Name: "X"}, ""},
|
|
{"apiKey ok query", SecurityScheme{ID: "k", Type: "apiKey", In: "query", Name: "X"}, ""},
|
|
{"apiKey ok cookie", SecurityScheme{ID: "k", Type: "apiKey", In: "cookie", Name: "X"}, ""},
|
|
{"http missing scheme", SecurityScheme{ID: "k", Type: "http"}, "scheme is required for http"},
|
|
{"http bearer ok", SecurityScheme{ID: "k", Type: "http", Scheme: "bearer"}, ""},
|
|
{"http basic ok", SecurityScheme{ID: "k", Type: "http", Scheme: "basic"}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := ValidateSecurityScheme(c.scheme)
|
|
if c.wantErr == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// ValidateToolConfig — full matrix
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestValidateToolConfig(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
config McpProxyToolConfig
|
|
wantErr string
|
|
}{
|
|
{
|
|
"missing name",
|
|
McpProxyToolConfig{Description: "d"},
|
|
"tool name is required",
|
|
},
|
|
{
|
|
"missing description",
|
|
McpProxyToolConfig{Name: "t"},
|
|
"tool description is required",
|
|
},
|
|
{
|
|
"arg missing name",
|
|
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{{Type: "string", Description: "x"}}},
|
|
"argument name is required",
|
|
},
|
|
{
|
|
"arg duplicate names",
|
|
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{
|
|
{Name: "a", Type: "string", Description: "x"},
|
|
{Name: "a", Type: "string", Description: "y"},
|
|
}},
|
|
"duplicate argument name",
|
|
},
|
|
{
|
|
"arg missing description",
|
|
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{{Name: "a", Type: "string"}}},
|
|
"argument description is required",
|
|
},
|
|
{
|
|
"arg invalid type",
|
|
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{{Name: "a", Type: "money", Description: "x"}}},
|
|
"invalid argument type",
|
|
},
|
|
{
|
|
"happy path with multiple typed args",
|
|
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{
|
|
{Name: "s", Type: "string", Description: "x"},
|
|
{Name: "n", Type: "number", Description: "x"},
|
|
{Name: "i", Type: "integer", Description: "x"},
|
|
{Name: "b", Type: "boolean", Description: "x"},
|
|
{Name: "a", Type: "array", Description: "x"},
|
|
{Name: "o", Type: "object", Description: "x"},
|
|
}},
|
|
"",
|
|
},
|
|
{
|
|
"happy path no args",
|
|
McpProxyToolConfig{Name: "t", Description: "d"},
|
|
"",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := ValidateToolConfig(c.config)
|
|
if c.wantErr == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|